The handoff was familiar. The original team had moved on, the service kept production traffic, and the API documentation was a Postman collection last touched in 2023 plus a Confluence page nobody trusted. There were roughly two hundred routes across an Express app and two smaller FastAPI services, a handful of SSE streams, and authentication that behaved differently depending on which router mounted the route.

Three options were on the table. Put a proxy in front of staging and record traffic — fast, but it only sees the paths someone exercises, and error branches stay invisible forever. Hand-write an OpenAPI document — accurate for a week, then drifting by the next sprint. Ask a coding agent to read screenshots of the codebase — fast, and confidently wrong about field nullability.

We used the fourth option: scan the code into a spec, review every route, and keep the scan repeatable.

What the scanner reads

The scanner parses source with an AST engine, not regular expressions. That distinction matters once routing leaves the obvious path. In the Express service, routes were registered across nested routers — app.use("/v1/admin", adminRouter), then router.use("/projects", projectRouter), then a handler bound to post("/:id/history/compare"). A text search finds the handler but loses the prefixes; the AST walk follows router mounting and reconstructs the full path, parameters included.

Schemas come from types wherever the language carries them. TypeScript interfaces, Pydantic models, Java DTOs, Go structs, and C# models resolve into request bodies, path and query parameters, and response shapes. Strongly typed handlers produce complete operations with no AI in the loop at all. Untyped JavaScript handlers are handled differently, on purpose: instead of inventing a plausible schema, the scanner marks the gap.

The engine covers eight languages — TypeScript, JavaScript, Python, Go, Java, C#, Rust, and PHP — through 28 framework packs including Express, Fastify, NestJS and Koa; FastAPI, Flask and Django REST; Gin, Chi, Echo and net/http; Spring and JAX-RS; ASP.NET; Axum and Actix-Web; Laravel and Symfony. HTTP and SSE are both first-class. The scan runs entirely on the machine; source code is never uploaded.

The review gate is the product

A raw scan result is not imported silently. The dialog lists every discovered operation with its method, full path, a confidence level, and an explicit gap report. In our import the honest gaps were the most useful part:

GapWhat it actually meant
body-schema-unknownA handler accepted req.body and passed it straight to an untyped service layer — nobody, including the type system, knew the shape.
response-schema-unknownThe success case was typed, but the error branch returned a plain object literal the framework could not see.
auth-unknownThe route sat behind middleware applied in a different file; the scanner would not guess whether it was public.
sse-events-unknownAn event stream existed, but the emitted event names and payload types were assembled dynamically.

Nothing becomes an unknown field in the finished document without being surfaced first. For the gaps worth filling, the optional AI resolver sends the relevant handler — and only that handler — to the model configured in the workspace, proposes a schema, and waits for approval. The default run has no AI step; turning it on measurably raises recall on loosely typed code, and the human review stays in the loop either way.

What the first scan caught

A few findings paid for the exercise immediately:

  • One controller returned different DTOs depending on the status code. The spec ended up with two explicit response schemas instead of one optimistic 200.
  • The SSE endpoint was recorded as text/event-stream with an item schema under the x-protocol extension — the same shape the rest of the workspace already uses for mock streams and scenario tests.
  • A route registered in two places with different middleware was flagged rather than silently merged, which surfaced a real auth inconsistency.
  • Several handlers accepted query parameters that never appeared in the old Postman collection because nobody had clicked that tab in years.

Rescans are diffs, not rewrites

After the first confirmed import, the scanner writes a .powerduck/discovery.json sidecar next to the workspace. Sprint two, we scanned again and got a change set instead of a fresh document: five new routes, two removed, one renamed path parameter. New operations can be merged into the existing spec; the descriptions, examples, and manual edits from the first pass are preserved. That is what makes the document survive — the cost of keeping it honest drops to a rescan and a five-minute review.

The limits are stated plainly. Routes assembled from configuration files, handlers dispatched through deeply dynamic proxies, and completely untyped request bodies stay marked as gaps rather than guessed. That honesty is the feature: a spec with a flagged gap can be fixed deliberately, while a spec with a confident guess fails in production at the worst time.

A scanned document is only the starting material. The next post covers what we did with it — mocks for downstream teams, scenario runs against staging, and an MCP server for internal agents.