← back to Cli Printing Press
feat(cli): add adaptive rate limiting for sniffed APIs (#62)
e26505e5cfbf450dd648e2eb9194b3da35e514a7 · 2026-03-29 20:46:49 -0700 · Trevin Chow
* feat(cli): add adaptive rate limiting for sniffed APIs
Generated CLIs now include a proactive adaptive rate limiter that
prevents 429s instead of recovering from them:
- Starts at a conservative floor (2 req/s for sniffed APIs, disabled
for official)
- Ramps up 25% after 10 consecutive successes
- Halves rate on 429 and discovers the ceiling
- Caps future increases at 90% of discovered ceiling
- Per-session only — starts fresh each run
Implementation:
- Add SpecSource field to spec.APISpec, plumbed from --spec-source flag
- Add adaptiveLimiter to client.go.tmpl (stdlib only, no x/time dep)
- Add --rate-limit flag to root.go.tmpl (default 2 for sniffed, 0 for
official)
- Show effective rate in sync progress output
- Add Sniff Pacing instructions to SKILL.md for Claude to pace API
probing during endpoint discovery
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): add thread safety and input validation to rate limiter
- Add sync.Mutex to adaptiveLimiter for concurrent sync workers
- Validate --spec-source flag values (official|community|sniffed|docs)
- Document zero-value lastRequest intent (first call skips wait)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(cli): add compound learning for adaptive rate limiting
Documents the adaptive rate limiting pattern for sniffed APIs as a
reusable best practice in docs/solutions/. Covers both skill-level
pacing (SKILL.md) and generated CLI rate limiter (client.go.tmpl).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Files touched
A docs/brainstorms/2026-03-29-sniffed-api-rate-limiting-requirements.mdA docs/plans/2026-03-29-001-feat-adaptive-rate-limiting-plan.mdA docs/solutions/best-practices/adaptive-rate-limiting-sniffed-apis.mdM internal/cli/root.goM internal/generator/templates/client.go.tmplM internal/generator/templates/mcp_tools.go.tmplM internal/generator/templates/root.go.tmplM internal/generator/templates/sync.go.tmplM internal/spec/spec.goM skills/printing-press/SKILL.md
Diff
commit e26505e5cfbf450dd648e2eb9194b3da35e514a7
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sun Mar 29 20:46:49 2026 -0700
feat(cli): add adaptive rate limiting for sniffed APIs (#62)
* feat(cli): add adaptive rate limiting for sniffed APIs
Generated CLIs now include a proactive adaptive rate limiter that
prevents 429s instead of recovering from them:
- Starts at a conservative floor (2 req/s for sniffed APIs, disabled
for official)
- Ramps up 25% after 10 consecutive successes
- Halves rate on 429 and discovers the ceiling
- Caps future increases at 90% of discovered ceiling
- Per-session only — starts fresh each run
Implementation:
- Add SpecSource field to spec.APISpec, plumbed from --spec-source flag
- Add adaptiveLimiter to client.go.tmpl (stdlib only, no x/time dep)
- Add --rate-limit flag to root.go.tmpl (default 2 for sniffed, 0 for
official)
- Show effective rate in sync progress output
- Add Sniff Pacing instructions to SKILL.md for Claude to pace API
probing during endpoint discovery
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): add thread safety and input validation to rate limiter
- Add sync.Mutex to adaptiveLimiter for concurrent sync workers
- Validate --spec-source flag values (official|community|sniffed|docs)
- Document zero-value lastRequest intent (first call skips wait)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(cli): add compound learning for adaptive rate limiting
Documents the adaptive rate limiting pattern for sniffed APIs as a
reusable best practice in docs/solutions/. Covers both skill-level
pacing (SKILL.md) and generated CLI rate limiter (client.go.tmpl).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
...03-29-sniffed-api-rate-limiting-requirements.md | 89 +++++++
...6-03-29-001-feat-adaptive-rate-limiting-plan.md | 295 +++++++++++++++++++++
.../adaptive-rate-limiting-sniffed-apis.md | 133 ++++++++++
internal/cli/root.go | 21 ++
internal/generator/templates/client.go.tmpl | 104 +++++++-
internal/generator/templates/mcp_tools.go.tmpl | 6 +-
internal/generator/templates/root.go.tmpl | 8 +-
internal/generator/templates/sync.go.tmpl | 16 +-
internal/spec/spec.go | 3 +-
skills/printing-press/SKILL.md | 22 +-
10 files changed, 687 insertions(+), 10 deletions(-)
diff --git a/docs/brainstorms/2026-03-29-sniffed-api-rate-limiting-requirements.md b/docs/brainstorms/2026-03-29-sniffed-api-rate-limiting-requirements.md
new file mode 100644
index 00000000..39354c23
--- /dev/null
+++ b/docs/brainstorms/2026-03-29-sniffed-api-rate-limiting-requirements.md
@@ -0,0 +1,89 @@
+---
+date: 2026-03-29
+topic: sniffed-api-rate-limiting
+---
+
+# Adaptive Rate Limiting for Sniffed APIs
+
+## Problem Frame
+
+Sniffed APIs are undocumented public endpoints reverse-engineered from browser traffic. They are designed for one browser making occasional calls, but our CLIs make bulk sequential requests — especially during sync (100-500+ paginated calls) and during the sniff discovery phase itself (10-30 probing calls). Without proactive throttling, these CLIs reliably hit 429 rate limits, wasting time on retries and risking IP bans.
+
+The current generated client handles 429s reactively (detect, wait 5s, retry up to 3x). This is insufficient for sniffed APIs because:
+- Each 429 wastes 5+ seconds in retry waits
+- Undocumented APIs may not send `Retry-After` headers
+- Some APIs escalate to IP bans after repeated 429s
+- Without auth, limits are per-IP — shared IPs (corp networks, VPNs) share the budget
+- Sync operations make hundreds of calls, so hitting the wall is guaranteed
+
+This was observed concretely during the postman-explore-pp-cli build: 429s hit during endpoint discovery (rapid XHR probing) and during sync pagination across categories.
+
+## Requirements
+
+**Adaptive Rate Limiter Algorithm**
+
+The same algorithm applies in two contexts — the sniff skill (behavioral instructions for Claude) and the generated CLI binary (compiled Go code). The implementation differs but the logic is identical.
+
+- R1. Start at a conservative floor: 1 req/s for sniff phase, 2 req/s for generated CLI
+- R2. After N consecutive successful requests (N=10 for CLI, N=5 for sniff), increase the rate by 25%
+- R3. On a 429 response, immediately halve the rate and record this as the "discovered ceiling"
+- R4. After 429 cooldown, resume at the halved rate. Cap future increases at 90% of the discovered ceiling
+- R5. The ceiling is per-session only — not persisted across runs. Each invocation starts fresh at the conservative floor
+
+**Sniff Phase (Skill Instructions)**
+
+- R6. Update the printing-press skill (SKILL.md) to instruct Claude to pace API probing during sniff using the adaptive algorithm (R1-R5 with sniff-phase defaults)
+- R7. Between browser-use eval calls that make API requests, apply the current rate delay before proceeding
+- R8. On 429 during sniffing, log the event, apply the backoff, and continue — do not abort discovery
+
+**Generated CLI (Client Template)**
+
+- R9. Add a `rate.Limiter` (from `golang.org/x/time/rate`) to the client struct in `client.go.tmpl`
+- R10. The limiter is active by default for sniffed APIs (`spec_source: sniffed` in the catalog). For official APIs, no limiter is active by default
+- R11. The limiter integrates with the existing retry loop — it runs before each request, not after. A request is only sent after the limiter grants a token
+- R12. Add a `--rate-limit` flag: accepts a number (req/s) for manual override, or `0` to disable. Default is `2` for sniffed APIs
+
+**Sync Pacing**
+
+- R13. Sync operations use the same rate limiter as all other requests (no separate mechanism)
+- R14. Sync progress output should show the effective rate when human-friendly: `"Syncing collections: 50/600 (category: AI) [1.8 req/s]"`
+
+## Success Criteria
+
+- A `printing-press generate` from a sniffed catalog entry produces a CLI that can `sync` 500+ pages without hitting any 429s at the default rate
+- The sniff phase in the skill can probe 30+ endpoints without hitting 429s
+- An agent running the CLI in a loop (search, browse, search, browse) does not accumulate 429s
+- `--rate-limit 0` disables the limiter for users who know their API can handle high throughput
+
+## Scope Boundaries
+
+- No distributed rate limiting across users or processes (server's job)
+- No persistence of discovered ceiling across sessions (undocumented APIs can change limits)
+- No rate limit detection via response time analysis (unreliable)
+- No changes to cache TTL — keep 5 min for all (defer if needed later)
+- No per-endpoint rate limit configuration (too granular, unknown values)
+
+## Key Decisions
+
+- **Same algorithm, two implementations**: The sniff skill and the generated CLI use the same adaptive logic. This is conceptually clean and avoids separate mental models.
+- **Conservative floor, not reactive-only**: Proactive throttling prevents 429s rather than recovering from them. The one-time cost of a slower first sync is worth the reliability.
+- **Per-session ceiling**: Not persisting the discovered ceiling keeps things simple and safe — undocumented APIs can change limits without notice.
+- **spec_source as the signal**: No new catalog fields for rate limiting. The existing `spec_source: sniffed` field (from PR #61) is sufficient to trigger the limiter.
+
+## Dependencies / Assumptions
+
+- `golang.org/x/time/rate` is the Go standard for token-bucket rate limiting — zero external dependencies beyond the Go extended library
+- The `spec_source` field is already in the catalog schema (PR #61 merged)
+- The generator templates have access to the catalog entry metadata at generation time (need to verify this is plumbed through)
+
+## Outstanding Questions
+
+### Deferred to Planning
+- [Affects R9][Technical] How does the generator template access the catalog entry's `spec_source` at generation time? Is it available in the template context, or does it need to be plumbed through?
+- [Affects R10][Technical] Should the limiter be a compile-time decision (template conditional) or a runtime decision (config file check)? Compile-time is simpler but means official APIs can't opt in without regeneration.
+- [Affects R6][Needs research] What specific sections of the printing-press skill need updating for the sniff pacing instructions? Review the current sniff gate flow in SKILL.md.
+- [Affects R12][Technical] Should `--rate-limit` accept `auto` as a value to explicitly enable adaptive mode, or is adaptive always-on the only mode?
+
+## Next Steps
+
+→ `/ce:plan` for structured implementation planning
diff --git a/docs/plans/2026-03-29-001-feat-adaptive-rate-limiting-plan.md b/docs/plans/2026-03-29-001-feat-adaptive-rate-limiting-plan.md
new file mode 100644
index 00000000..a0d81dee
--- /dev/null
+++ b/docs/plans/2026-03-29-001-feat-adaptive-rate-limiting-plan.md
@@ -0,0 +1,295 @@
+---
+title: "feat: Add adaptive rate limiting for sniffed APIs"
+type: feat
+status: active
+date: 2026-03-29
+origin: docs/brainstorms/2026-03-29-sniffed-api-rate-limiting-requirements.md
+---
+
+# feat: Add Adaptive Rate Limiting for Sniffed APIs
+
+## Overview
+
+Add proactive rate limiting to the printing-press generator and sniff skill. Generated CLIs for sniffed APIs will start at a conservative request rate and adaptively find the optimal speed. The sniff skill will add pacing instructions so Claude doesn't burn through rate limits during endpoint discovery.
+
+## Problem Frame
+
+Sniffed APIs are undocumented endpoints designed for one browser. Our CLIs make 100-500+ sequential requests during sync and 10-30 during sniff discovery, reliably hitting 429 rate limits. The current reactive retry-on-429 approach is insufficient — each retry wastes 5+ seconds, and some APIs escalate to IP bans. (see origin: docs/brainstorms/2026-03-29-sniffed-api-rate-limiting-requirements.md)
+
+## Requirements Trace
+
+- R1-R5. Adaptive rate limiter algorithm (conservative floor, ramp-up on success, halve on 429, per-session ceiling)
+- R6-R8. Sniff skill pacing instructions
+- R9-R12. Rate limiter in generated client template with `--rate-limit` flag
+- R13-R14. Sync uses same limiter, shows effective rate in progress output
+
+## Scope Boundaries
+
+- No distributed rate limiting, no ceiling persistence across sessions, no response-time analysis
+- No cache TTL changes, no per-endpoint rate config
+- No `golang.org/x/time/rate` dependency — use stdlib for zero added deps in generated CLIs
+- (see origin for full list)
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/spec/spec.go:10-21` — `APISpec` struct, the template data context. Currently has no `SpecSource` field.
+- `internal/generator/generator.go:123` — `client.go.tmpl` receives `*spec.APISpec` directly
+- `internal/generator/generator.go:386-396` — `root.go.tmpl` receives anonymous struct wrapping `*spec.APISpec`
+- `internal/generator/templates/client.go.tmpl:199` — existing 429 retry logic
+- `internal/generator/templates/root.go.tmpl:49-63` — existing flag registration
+- `skills/printing-press/SKILL.md:500-570` — sniff gate browser-use/agent-browser sections with basic `sleep` delays
+
+### Key Findings
+
+- Catalog metadata (`SpecSource`, `ClientPattern`) is NOT currently available during generation — `spec.APISpec` doesn't have these fields and the catalog entry isn't passed to the generator
+- `golang.org/x/time` is not a current dependency and shouldn't be added to generated CLIs
+- The sniff skill has `sleep 4` and `sleep 1` placeholders but no structured pacing
+
+## Key Technical Decisions
+
+- **Stdlib rate limiter, not `x/time/rate`**: Generated CLIs should have minimal dependencies. A 40-line token-bucket limiter using `time.Sleep` and `time.Since` avoids adding `golang.org/x/time` to every generated CLI's `go.mod`. The algorithm is simple enough that stdlib covers it.
+- **`SpecSource` field on `APISpec`**: Add a `SpecSource string` field to `spec.APISpec`. The generate CLI command sets it from the catalog entry (when using `catalog show`) or from a new `--spec-source` flag. Templates use `{{.SpecSource}}` to conditionally set defaults.
+- **Always include limiter, vary the default**: The rate limiter code is present in every generated CLI. When `SpecSource == "sniffed"`, the default rate is 2 req/s. Otherwise the default is 0 (disabled). Users override with `--rate-limit`. No template conditionals for inclusion — just for default values.
+- **Adaptive is always-on**: When rate > 0, the ceiling-finder runs automatically. No separate "auto" mode. `--rate-limit N` sets the starting rate AND enables adaptive behavior. `--rate-limit 0` disables entirely.
+
+## Open Questions
+
+### Resolved During Planning
+
+- **How does the template access `spec_source`?** Add `SpecSource string` to `spec.APISpec`. The CLI sets it before calling the generator. Templates access it as `{{.SpecSource}}`.
+- **Compile-time or runtime?** Compile-time default via template `{{if eq .SpecSource "sniffed"}}`. The code is always present, only the default value changes.
+- **Should `--rate-limit` accept `auto`?** No. Adaptive is always-on when rate > 0. Simpler mental model.
+- **What SKILL.md sections need updating?** Step 2a.2 (page collection loop, lines 500-512), Step 2a.4 (fetch loop, lines 526-533), Step 2b.2 (agent-browser loop, lines 553-560), Step 2b.3 (response body fetching, lines 562-570).
+
+### Deferred to Implementation
+
+- Exact ramp-up thresholds (N=10 successes, 25% increase) may need tuning after testing against postman-explore
+- The sniff skill pacing is behavioral guidance — Claude's adherence may vary; verify with a real sniff run
+
+## 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.*
+
+```
+Rate Limiter (stdlib, ~40 lines in client.go.tmpl):
+
+ struct adaptiveLimiter {
+ rate float64 // current requests per second
+ floor float64 // starting rate (e.g. 2.0)
+ ceiling float64 // discovered ceiling (0 = unknown)
+ successes int // consecutive successes since last 429
+ rampAfter int // successes needed to increase (10)
+ lastRequest time.Time
+ }
+
+ Wait():
+ delay = 1/rate seconds
+ elapsed = time.Since(lastRequest)
+ if elapsed < delay: sleep(delay - elapsed)
+ lastRequest = now
+
+ OnSuccess():
+ successes++
+ if successes >= rampAfter:
+ newRate = rate * 1.25
+ if ceiling > 0: cap at ceiling * 0.9
+ rate = newRate
+ successes = 0
+
+ OnRateLimit():
+ ceiling = rate
+ rate = rate / 2
+ if rate < 0.5: rate = 0.5 // absolute minimum
+ successes = 0
+
+Integration in do() loop:
+ if limiter != nil: limiter.Wait()
+ resp = httpClient.Do(req)
+ if resp.StatusCode == 429: limiter.OnRateLimit()
+ else if resp.StatusCode < 400: limiter.OnSuccess()
+```
+
+## Implementation Units
+
+- [ ] **Unit 1: Add SpecSource to APISpec and plumb through generator**
+
+**Goal:** Make catalog metadata available to templates at generation time.
+
+**Requirements:** R10 (limiter active for sniffed APIs)
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/spec/spec.go`
+- Modify: `internal/cli/root.go` (generate command)
+- Test: `internal/spec/spec_test.go`
+- Test: `internal/generator/generator_test.go`
+
+**Approach:**
+- Add `SpecSource string` field to `APISpec` struct with `yaml:"spec_source,omitempty"`
+- In the generate CLI command, after parsing the spec, set `apiSpec.SpecSource` from either:
+ - The catalog entry's `SpecSource` (when generating from a catalog API)
+ - A new `--spec-source` flag (when generating from a raw spec)
+ - Default to empty string (limiter disabled)
+- Verify templates can access `{{.SpecSource}}`
+
+**Patterns to follow:**
+- Existing `APISpec` fields like `Owner` which are optional metadata
+- Existing `--force`, `--lenient` flags on the generate command
+
+**Test scenarios:**
+- Happy path: APISpec with SpecSource="sniffed" round-trips through YAML marshal/unmarshal
+- Happy path: Generated CLI from a catalog entry with spec_source=sniffed has SpecSource set in template context
+- Edge case: SpecSource empty string (default) — template conditionals produce disabled-limiter defaults
+- Edge case: --spec-source flag overrides catalog value when both present
+
+**Verification:**
+- `go test ./internal/spec/... ./internal/generator/...` passes
+- A generated CLI from the postman-explore catalog entry includes sniffed-aware defaults
+
+- [ ] **Unit 2: Add adaptive rate limiter to client.go.tmpl**
+
+**Goal:** Every generated CLI includes a stdlib rate limiter. Active by default for sniffed APIs, disabled for official.
+
+**Requirements:** R1-R5, R9-R11
+
+**Dependencies:** Unit 1
+
+**Files:**
+- Modify: `internal/generator/templates/client.go.tmpl`
+- Test: `internal/generator/generator_test.go`
+
+**Approach:**
+- Add `adaptiveLimiter` struct and methods (Wait, OnSuccess, OnRateLimit) to the client template
+- Add `limiter *adaptiveLimiter` field to `Client` struct
+- In `New()`, initialize limiter based on template conditional: `{{if eq .SpecSource "sniffed"}}` sets floor to 2.0 req/s, else nil (disabled)
+- In `do()`, call `limiter.Wait()` before sending the request, `limiter.OnSuccess()` on 2xx, `limiter.OnRateLimit()` on 429 (before the existing retry logic)
+- The existing 429 retry loop stays — the limiter adjusts the rate, the retry loop handles the actual wait-and-retry
+
+**Patterns to follow:**
+- Existing `Client` struct fields (`DryRun`, `NoCache`, `cacheDir`)
+- Existing `retryAfter()` helper
+
+**Test scenarios:**
+- Happy path: Generate a CLI from a sniffed spec, verify client.go contains `adaptiveLimiter` initialized with floor=2.0
+- Happy path: Generate a CLI from an official spec, verify client.go has nil limiter
+- Edge case: Limiter nil (disabled) — all requests proceed without delay
+- Integration: The adaptive algorithm — after 10 successes the rate increases, after 429 the rate halves, ceiling is respected at 90%
+
+**Verification:**
+- Generated client.go compiles with no new external dependencies
+- `go test ./internal/generator/...` passes
+
+- [ ] **Unit 3: Add --rate-limit flag to root.go.tmpl**
+
+**Goal:** Users can override the rate limit via CLI flag.
+
+**Requirements:** R12
+
+**Dependencies:** Unit 2
+
+**Files:**
+- Modify: `internal/generator/templates/root.go.tmpl`
+- Modify: `internal/generator/templates/client.go.tmpl` (accept rate from flag)
+- Test: `internal/generator/generator_test.go`
+
+**Approach:**
+- Add `rateLimit float64` to `rootFlags` struct
+- Register `--rate-limit` persistent flag with default from template: `{{if eq .SpecSource "sniffed"}}2{{else}}0{{end}}`
+- Pass the flag value to `client.New()` or set it on the client after creation
+- Value of 0 means disabled (no limiter created)
+- Value > 0 creates limiter with that floor rate
+
+**Patterns to follow:**
+- Existing `--timeout` flag which is similarly passed to the client
+
+**Test scenarios:**
+- Happy path: `--rate-limit 5` sets limiter floor to 5 req/s
+- Happy path: `--rate-limit 0` disables limiter entirely
+- Edge case: Default for sniffed API is 2, default for official is 0
+- Edge case: Negative value treated as 0 (disabled)
+
+**Verification:**
+- Generated CLI's `--help` shows `--rate-limit` with correct default
+- Flag value correctly propagates to client limiter
+
+- [ ] **Unit 4: Show effective rate in sync progress**
+
+**Goal:** Sync output displays the current request rate for observability.
+
+**Requirements:** R13, R14
+
+**Dependencies:** Unit 2
+
+**Files:**
+- Modify: `internal/generator/templates/sync.go.tmpl`
+- Modify: `internal/generator/templates/client.go.tmpl` (expose current rate getter)
+
+**Approach:**
+- Add `Rate() float64` method to `adaptiveLimiter` that returns current rate (0 if nil)
+- Add `RateLimit() float64` method to `Client` that delegates to limiter
+- In sync progress output, include `[%.1f req/s]` when rate > 0
+- Only show in human-friendly mode; JSON progress events include `rate_rps` field
+
+**Patterns to follow:**
+- Existing sync progress format: `{"event":"sync_progress","resource":"...","fetched":N}`
+
+**Test scenarios:**
+- Happy path: Sync with limiter active shows rate in progress — human-friendly format includes `[2.0 req/s]`
+- Happy path: JSON progress event includes `rate_rps` field
+- Edge case: Limiter disabled (rate=0) — no rate shown in output
+
+**Verification:**
+- Sync progress includes rate information when limiter is active
+- JSON and human-friendly output both include rate data in their respective formats
+
+- [ ] **Unit 5: Update SKILL.md sniff gate with pacing instructions**
+
+**Goal:** Claude paces API probing during sniff discovery using the adaptive algorithm.
+
+**Requirements:** R6-R8
+
+**Dependencies:** None (parallel with Units 1-4)
+
+**Files:**
+- Modify: `skills/printing-press/SKILL.md`
+
+**Approach:**
+- Add a "Sniff Pacing" subsection after "If user approves sniff" (around line 402)
+- Document the adaptive algorithm with sniff-phase defaults: floor=1 req/s, ramp after 5 successes, halve on 429
+- Replace the hard-coded `sleep 4` and `sleep 1` in Step 2a.2 with a pacing instruction: "Apply the current sniff delay (starting at 1 second) between eval calls. If the previous call succeeded, decrease delay by 20% (min 0.3s). On 429, double the delay and log the event."
+- Add 429 recovery guidance to Step 2a.4 and Step 2b sections
+- Add guidance: "If you hit 3 consecutive 429s, pause for 30 seconds before continuing"
+
+**Patterns to follow:**
+- Existing skill instruction style — imperative, concise, with code examples
+
+**Test scenarios:**
+Test expectation: none — skill instruction changes are behavioral guidance for Claude, not compiled code. Validated through real sniff runs.
+
+**Verification:**
+- SKILL.md contains clear pacing instructions in the sniff gate section
+- Instructions reference the adaptive algorithm with sniff-phase parameters
+
+## System-Wide Impact
+
+- **Generated CLI dependencies**: No new external dependencies added. Limiter uses stdlib only.
+- **Backward compatibility**: Existing generated CLIs are unaffected until regenerated. The `--rate-limit` flag defaults to 0 (disabled) for non-sniffed APIs.
+- **Template API surface**: `spec.APISpec` gains one field (`SpecSource`). All existing templates continue to work — the field is optional.
+- **Sync behavior change**: Sync for sniffed APIs will be slower by default (2 req/s floor) but more reliable (no 429s). This is the intended tradeoff.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Adaptive algorithm too aggressive — still hits 429s at 2 req/s floor | Floor is conservative. If real-world testing shows issues, lower to 1 req/s. Easy to tune the default. |
+| Adaptive algorithm too conservative — sync is painfully slow | Users override with `--rate-limit 5` or higher. Ceiling-finder will also ramp up over time. |
+| SKILL.md pacing instructions ignored by Claude | The instructions are guidance, not enforcement. If Claude ignores them, the existing reactive 429 handling is the fallback. |
+
+## Sources & References
+
+- **Origin document:** [docs/brainstorms/2026-03-29-sniffed-api-rate-limiting-requirements.md](docs/brainstorms/2026-03-29-sniffed-api-rate-limiting-requirements.md)
+- Related code: `internal/spec/spec.go`, `internal/generator/templates/client.go.tmpl`
+- Related PRs: #60 (smart-default output), #61 (catalog schema with spec_source)
diff --git a/docs/solutions/best-practices/adaptive-rate-limiting-sniffed-apis.md b/docs/solutions/best-practices/adaptive-rate-limiting-sniffed-apis.md
new file mode 100644
index 00000000..850149bc
--- /dev/null
+++ b/docs/solutions/best-practices/adaptive-rate-limiting-sniffed-apis.md
@@ -0,0 +1,133 @@
+---
+title: Adaptive Rate Limiting for Sniffed API CLIs
+date: 2026-03-29
+category: best-practices
+module: generator
+problem_type: best_practice
+component: tooling
+severity: medium
+applies_when:
+ - generating CLIs from sniffed or undocumented APIs
+ - sniffing live sites for endpoint discovery
+ - bulk pagination over reverse-engineered endpoints
+tags:
+ - rate-limiting
+ - sniffed-api
+ - adaptive-throttling
+ - code-generation
+ - proxy-pattern
+---
+
+# Adaptive Rate Limiting for Sniffed API CLIs
+
+## Context
+
+Sniffed APIs are undocumented public endpoints reverse-engineered from browser traffic. They are designed for a single browser making occasional calls, but printing-press CLIs make bulk sequential requests -- 100-500+ calls during sync and 10-30 during sniff discovery.
+
+This was discovered building postman-explore-pp-cli. The Postman Explore site rate-limited after ~10 rapid XHR calls during endpoint discovery, and sync pagination across 12 categories triggered repeated 429s. The reactive retry-on-429 approach (already in the generator) was insufficient: each retry wasted 5+ seconds, and repeated 429s risked IP bans.
+
+The core insight: **prevention is cheaper than recovery**. A proactive throttle that starts conservative and adapts is more reliable than detecting and recovering from rate limits.
+
+## Guidance
+
+The solution has two layers that use the same algorithm with different defaults:
+
+### 1. Skill-level pacing (SKILL.md)
+
+Instructions for Claude to pace API probing during the sniff gate:
+
+- Start at 1 second between API calls
+- After 5 consecutive successful calls, reduce delay by 20% (min 0.3s)
+- On 429, double the delay and log the event
+- On 3 consecutive 429s, pause 30 seconds
+- Never abort discovery due to rate limits
+
+This is behavioral guidance, not compiled code. Claude follows it during browser-use eval calls.
+
+### 2. Generated CLI rate limiter (client.go.tmpl)
+
+A stdlib adaptive rate limiter compiled into every generated CLI:
+
+- For sniffed APIs (`spec_source: sniffed`), defaults to 2 req/s
+- For official APIs, disabled by default (0 req/s = no limiter)
+- Users override with `--rate-limit N` (0 to disable)
+- Thread-safe via `sync.Mutex` for concurrent sync workers
+
+The algorithm (TCP-congestion-control-inspired):
+
+1. Start at conservative floor (2 req/s)
+2. After 10 consecutive successes, increase rate by 25%
+3. On 429, halve rate and record discovered ceiling
+4. Cap future increases at 90% of ceiling
+5. Per-session only -- not persisted across runs
+
+### Design decisions
+
+- **Same algorithm, two implementations**: Skill instructions and compiled Go code use identical logic. One mental model for both contexts.
+- **`spec_source` as the signal**: The existing `spec_source: sniffed` catalog field (PR #61) gates whether rate limiting activates. No new schema fields needed.
+- **Stdlib only**: Uses `time.Sleep`/`time.Since` and `sync.Mutex`. No `golang.org/x/time/rate` dependency added to generated CLIs.
+- **Always present, default varies**: The limiter code exists in every generated CLI. Only the default rate changes based on `spec_source`. This means official API users can opt in with `--rate-limit 2`.
+
+## Why This Matters
+
+Without proactive rate limiting, every sniffed CLI sync is guaranteed to hit 429s. The impact compounds:
+
+- Each 429 wastes 5+ seconds in retry waits
+- Undocumented APIs may not send `Retry-After` headers (default fallback is 5s)
+- Some APIs escalate to IP bans after repeated 429s
+- Without auth, limits are per-IP -- shared IPs (corp networks, VPNs) share the budget
+- Agents running CLIs in loops (search, browse, search) accumulate 429s invisibly
+
+The adaptive approach finds the optimal speed per-session. A sync that would have hit the wall 10+ times at full speed completes with zero 429s at a small throughput cost.
+
+## When to Apply
+
+- When the API has `spec_source: sniffed` in the catalog
+- When generating a CLI with `--spec-source sniffed`
+- When the skill's sniff gate is probing a live site for endpoints
+- When sync operations paginate across many categories/resources on an undocumented API
+
+Do **not** apply for official APIs with published rate limits and `Retry-After` headers. Those should use the existing reactive retry logic.
+
+## Examples
+
+**Before (reactive only):**
+```
+sync start: 200 pages at full speed
+page 8: 429 → wait 5s → retry
+page 15: 429 → wait 5s → retry
+page 23: 429 → wait 5s → retry
+...
+Total: 200 pages + 10 retries × 5s = 200 pages + 50s wasted
+```
+
+**After (adaptive rate limiter at 2 req/s):**
+```
+sync start: 2 req/s → no 429s through page 100
+page 100: rate ramped to ~4 req/s via ceiling-finder
+page 150: 429 → halve to 2.5 req/s, ceiling discovered at 5 req/s
+remaining: steady at 4.5 req/s (90% of ceiling)
+Total: 200 pages, 1 retry, ~60s total
+```
+
+**Generated CLI usage:**
+```bash
+# Sniffed API: rate limiting active by default (2 req/s)
+postman-explore-pp-cli sync --resources collections
+
+# Override to faster rate
+postman-explore-pp-cli sync --rate-limit 5
+
+# Disable for testing
+postman-explore-pp-cli sync --rate-limit 0
+
+# Sync progress shows effective rate
+# {"event":"sync_progress","resource":"collections","fetched":50,"rate_rps":2.5}
+```
+
+## Related
+
+- PR #61: Added `spec_source`, `auth_required`, `client_pattern` to catalog schema
+- PR #62: Implemented adaptive rate limiter in generator templates
+- `docs/plans/2026-03-29-001-feat-adaptive-rate-limiting-plan.md`: Implementation plan
+- `docs/brainstorms/2026-03-29-sniffed-api-rate-limiting-requirements.md`: Requirements doc
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 6d051d01..2974e316 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -65,6 +65,7 @@ func newGenerateCmd() *cobra.Command {
var polish bool
var asJSON bool
var dryRun bool
+ var specSource string
cmd := &cobra.Command{
Use: "generate",
@@ -115,6 +116,16 @@ func newGenerateCmd() *cobra.Command {
if err != nil {
return &ExitError{Code: ExitSpecError, Err: fmt.Errorf("parsing generated spec: %w", err)}
}
+ if specSource != "" {
+ switch specSource {
+ case "official", "community", "sniffed", "docs":
+ parsed.SpecSource = specSource
+ default:
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--spec-source must be one of: official, community, sniffed, docs (got %q)", specSource)}
+ }
+ } else {
+ parsed.SpecSource = "docs"
+ }
explicitOutput := outputDir != ""
if outputDir == "" {
@@ -212,6 +223,15 @@ func newGenerateCmd() *cobra.Command {
apiSpec = mergeSpecs(specs, cliName)
}
+ if specSource != "" {
+ switch specSource {
+ case "official", "community", "sniffed", "docs":
+ apiSpec.SpecSource = specSource
+ default:
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--spec-source must be one of: official, community, sniffed, docs (got %q)", specSource)}
+ }
+ }
+
explicitOutput := outputDir != ""
if outputDir == "" {
outputDir = pipeline.DefaultOutputDir(apiSpec.Name)
@@ -299,6 +319,7 @@ func newGenerateCmd() *cobra.Command {
cmd.Flags().BoolVar(&polish, "polish", false, "Run LLM polish pass on generated CLI (requires claude or codex CLI)")
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)")
return cmd
}
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index c814eb77..f10cd9a7 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -16,6 +16,7 @@ import (
"path/filepath"
"strconv"
"strings"
+ "sync"
"time"
"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/config"
@@ -28,6 +29,92 @@ type Client struct {
DryRun bool
NoCache bool
cacheDir string
+ limiter *adaptiveLimiter
+}
+
+// adaptiveLimiter provides proactive rate limiting with adaptive ceiling discovery.
+// Starts at a conservative floor rate and ramps up after consecutive successes.
+// On 429, halves the rate and records a ceiling. Per-session only — not persisted.
+// Thread-safe: all methods are guarded by a mutex for concurrent sync workers.
+type adaptiveLimiter struct {
+ mu sync.Mutex
+ rate float64 // current requests per second
+ floor float64 // starting/minimum rate
+ ceiling float64 // discovered ceiling (0 = unknown)
+ successes int // consecutive successes since last 429
+ rampAfter int // successes needed before increasing rate
+ lastRequest time.Time // zero-value on init — first call skips wait (intentional)
+}
+
+func newAdaptiveLimiter(ratePerSec float64) *adaptiveLimiter {
+ if ratePerSec <= 0 {
+ return nil
+ }
+ return &adaptiveLimiter{
+ rate: ratePerSec,
+ floor: ratePerSec,
+ rampAfter: 10,
+ }
+}
+
+// Wait blocks until the rate limiter allows the next request.
+func (l *adaptiveLimiter) Wait() {
+ if l == nil {
+ return
+ }
+ l.mu.Lock()
+ delay := time.Duration(float64(time.Second) / l.rate)
+ elapsed := time.Since(l.lastRequest)
+ l.mu.Unlock()
+ if elapsed < delay {
+ time.Sleep(delay - elapsed)
+ }
+ l.mu.Lock()
+ l.lastRequest = time.Now()
+ l.mu.Unlock()
+}
+
+// OnSuccess records a successful request and ramps up the rate after enough consecutive successes.
+func (l *adaptiveLimiter) OnSuccess() {
+ if l == nil {
+ return
+ }
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ l.successes++
+ if l.successes >= l.rampAfter {
+ newRate := l.rate * 1.25
+ if l.ceiling > 0 && newRate > l.ceiling*0.9 {
+ newRate = l.ceiling * 0.9
+ }
+ l.rate = newRate
+ l.successes = 0
+ }
+}
+
+// OnRateLimit records a 429 response — halves the rate and discovers the ceiling.
+func (l *adaptiveLimiter) OnRateLimit() {
+ if l == nil {
+ return
+ }
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ l.ceiling = l.rate
+ l.rate = l.rate / 2
+ if l.rate < 0.5 {
+ l.rate = 0.5 // absolute minimum: 1 request per 2 seconds
+ }
+ l.successes = 0
+}
+
+// Rate returns the current rate in requests per second. Returns 0 if the limiter is nil.
+func (l *adaptiveLimiter) Rate() float64 {
+ if l == nil {
+ return 0
+ }
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.rate
}
// APIError carries HTTP status information for structured exit codes.
@@ -42,7 +129,7 @@ func (e *APIError) Error() string {
return fmt.Sprintf("%s %s returned HTTP %d: %s", e.Method, e.Path, e.StatusCode, e.Body)
}
-func New(cfg *config.Config, timeout time.Duration) *Client {
+func New(cfg *config.Config, timeout time.Duration, rateLimit float64) *Client {
homeDir, _ := os.UserHomeDir()
cacheDir := filepath.Join(homeDir, ".cache", "{{.Name}}-pp-cli")
return &Client{
@@ -50,9 +137,15 @@ func New(cfg *config.Config, timeout time.Duration) *Client {
Config: cfg,
HTTPClient: &http.Client{Timeout: timeout},
cacheDir: cacheDir,
+ limiter: newAdaptiveLimiter(rateLimit),
}
}
+// RateLimit returns the current effective rate limit in req/s. Returns 0 if disabled.
+func (c *Client) RateLimit() float64 {
+ return c.limiter.Rate()
+}
+
func (c *Client) Get(path string, params map[string]string) (json.RawMessage, error) {
// Check cache for GET requests
if !c.NoCache && c.cacheDir != "" {
@@ -132,6 +225,9 @@ func (c *Client) do(method, path string, params map[string]string, body any) (js
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
+ // Proactive rate limiting — wait before sending
+ c.limiter.Wait()
+
var bodyReader io.Reader
if bodyBytes != nil {
bodyReader = strings.NewReader(string(bodyBytes))
@@ -185,6 +281,7 @@ func (c *Client) do(method, path string, params map[string]string, body any) (js
// Success
if resp.StatusCode < 400 {
+ c.limiter.OnSuccess()
return json.RawMessage(respBody), resp.StatusCode, nil
}
@@ -195,10 +292,11 @@ func (c *Client) do(method, path string, params map[string]string, body any) (js
Body: truncateBody(respBody),
}
- // Rate limited - wait and retry
+ // Rate limited - adjust adaptive limiter and retry
if resp.StatusCode == 429 && attempt < maxRetries {
+ c.limiter.OnRateLimit()
wait := retryAfter(resp)
- fmt.Fprintf(os.Stderr, "rate limited, waiting %s (attempt %d/%d)\n", wait, attempt+1, maxRetries)
+ fmt.Fprintf(os.Stderr, "rate limited, waiting %s (attempt %d/%d, rate adjusted to %.1f req/s)\n", wait, attempt+1, maxRetries, c.limiter.Rate())
time.Sleep(wait)
lastErr = apiErr
continue
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index e11147bb..3dd92747 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -194,7 +194,11 @@ func newMCPClient() (*client.Client, error) {
if err != nil {
return nil, fmt.Errorf("loading config: %w", err)
}
- return client.New(cfg, 30*time.Second), nil
+{{- if eq .SpecSource "sniffed"}}
+ return client.New(cfg, 30*time.Second, 2), nil
+{{- else}}
+ return client.New(cfg, 30*time.Second, 0), nil
+{{- end}}
}
func dbPath() string {
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index ddf52437..7dc403ea 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -31,6 +31,7 @@ type rootFlags struct {
selectFields string
configPath string
timeout time.Duration
+ rateLimit float64
}
// Execute runs the CLI in non-interactive mode: never prompts, all values via flags or stdin.
@@ -61,6 +62,11 @@ func Execute() error {
rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "Disable colored output")
rootCmd.PersistentFlags().BoolVar(&humanFriendly, "human-friendly", false, "Enable colored output and rich formatting")
rootCmd.PersistentFlags().BoolVar(&flags.agent, "agent", false, "Set all agent-friendly defaults (--json --compact --no-input --no-color --yes)")
+{{- if eq .SpecSource "sniffed"}}
+ rootCmd.PersistentFlags().Float64Var(&flags.rateLimit, "rate-limit", 2, "Max requests per second (0 to disable, default 2 for sniffed APIs)")
+{{- else}}
+ rootCmd.PersistentFlags().Float64Var(&flags.rateLimit, "rate-limit", 0, "Max requests per second (0 to disable)")
+{{- end}}
rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
if flags.agent {
@@ -144,7 +150,7 @@ func (f *rootFlags) newClient() (*client.Client, error) {
if err != nil {
return nil, configErr(err)
}
- c := client.New(cfg, f.timeout)
+ c := client.New(cfg, f.timeout, f.rateLimit)
c.DryRun = f.dryRun
c.NoCache = f.noCache
return c, nil
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index cb085eb9..cac31a91 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -167,6 +167,7 @@ Once synced, use the 'search' command for instant full-text search.`,
// It resumes from the last cursor unless sinceTS or full mode overrides it.
func syncResource(c interface {
Get(string, map[string]string) (json.RawMessage, error)
+ RateLimit() float64
}, db *store.Store, resource, sinceTS string, full bool) syncResult {
started := time.Now()
@@ -245,11 +246,20 @@ func syncResource(c interface {
totalCount += len(items)
atomic.AddInt64(&progressCount, int64(len(items)))
- // Progress reporting
+ // Progress reporting (include rate limit info when active)
+ currentRate := c.RateLimit()
if humanFriendly {
- fmt.Fprintf(os.Stderr, "\r %s: %d synced", resource, atomic.LoadInt64(&progressCount))
+ if currentRate > 0 {
+ fmt.Fprintf(os.Stderr, "\r %s: %d synced [%.1f req/s]", resource, atomic.LoadInt64(&progressCount), currentRate)
+ } else {
+ fmt.Fprintf(os.Stderr, "\r %s: %d synced", resource, atomic.LoadInt64(&progressCount))
+ }
} else {
- fmt.Fprintf(os.Stderr, `{"event":"sync_progress","resource":"%s","fetched":%d}`+"\n", resource, atomic.LoadInt64(&progressCount))
+ if currentRate > 0 {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_progress","resource":"%s","fetched":%d,"rate_rps":%.1f}`+"\n", resource, atomic.LoadInt64(&progressCount), currentRate)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_progress","resource":"%s","fetched":%d}`+"\n", resource, atomic.LoadInt64(&progressCount))
+ }
}
// Save cursor after each page for resumability
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 7eb73cca..d14fbfbf 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -13,7 +13,8 @@ type APISpec struct {
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
+ 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"`
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index fd391a06..e659f403 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -401,6 +401,18 @@ Present to the user via `AskUserQuestion`:
### If user approves sniff
+#### Sniff Pacing
+
+When making API calls during sniff (browser-use eval, fetch, or direct HTTP requests), apply adaptive pacing to avoid rate limits:
+
+1. **Start conservative**: Wait 1 second between API calls
+2. **Ramp up on success**: After 5 consecutive successful calls, reduce the delay by 20% (minimum 0.3 seconds)
+3. **Back off on 429**: If you get a rate-limited response (HTTP 429), immediately double the delay and log: "Rate limited — increasing delay to Xs"
+4. **Hard stop on repeated 429s**: If you hit 3 consecutive 429s, pause for 30 seconds before continuing
+5. **Never abort**: Rate limiting during sniff is recoverable. Always continue after the backoff — do not abort discovery due to rate limits
+
+Track the current delay mentally. Report the effective rate when summarizing sniff results: "Sniffed N endpoints at ~X req/s effective rate."
+
#### Step 1: Detect capture tools
Check which browser automation tools are available:
@@ -499,9 +511,11 @@ SNIFF_URLS="$API_RUN_DIR/sniff-urls.txt"
# For EACH target page (run this loop in foreground — do NOT use run_in_background):
browser-use open "<target-page-url>"
-sleep 4 # Wait for API calls to complete
+sleep 4 # Wait for initial page load API calls to complete
+# Apply sniff pacing delay (starting at 1s, adapts per Sniff Pacing rules above)
browser-use scroll down # Trigger lazy-loaded content
sleep 1
+# Apply sniff pacing delay before next eval call
# Collect API URLs via Performance API (browser-native, no injection needed)
browser-use eval "var e=performance.getEntriesByType('resource');var u=[];for(var i=0;i<e.length;i++){var n=e[i].name;if(n.indexOf('<api-domain-1>')>-1||n.indexOf('<api-domain-2>')>-1)u.push(n);}u.join('|||');"
@@ -530,6 +544,9 @@ The Performance API gives us URLs but not response bodies. To feed `printing-pre
```bash
# For each unique API URL, fetch it and build a simple capture file
# printing-press sniff accepts HAR or enriched capture JSON
+# When fetching each unique API URL to build enriched capture:
+# Apply sniff pacing between requests (1s initial, adaptive per Sniff Pacing rules)
+# On 429: double delay, log, continue with remaining URLs
```
Alternatively, if the URL count is small enough, the unique path patterns alone are sufficient to identify what the existing spec is missing — compare against the spec and report the gap without needing full HAR capture.
@@ -558,6 +575,7 @@ If browser-use is not available, use agent-browser with Claude driving the explo
- Fill forms with realistic sample data based on the domain
- `agent-browser wait --network-idle` after each interaction
- Repeat for up to 5 rounds or until no new API endpoints appear for 2 consecutive rounds
+ - Apply sniff pacing between interactions (1s initial, adaptive per Sniff Pacing rules)
3. **Capture response bodies** (agent-browser HAR omits them):
```bash
@@ -566,6 +584,8 @@ If browser-use is not available, use agent-browser with Claude driving the explo
For each API request (filter by JSON content-type, skip analytics domains):
```bash
agent-browser network request <request-id> --json
+ # Apply sniff pacing between response body fetches
+ # These are direct API calls and most likely to trigger rate limits
```
Combine HAR metadata + response bodies into an enriched capture JSON at `$API_RUN_DIR/sniff-capture.json`.
← f5716d90 feat(cli): add spec_source, auth_required, client_pattern to
·
back to Cli Printing Press
·
fix(skills): require CLI description rewrite after generatio bafe3563 →