a YC-backed documentation platform

From a YAML file to a living API console

Turning an OpenAPI specification into a tree of editable documents — every endpoint a rich-text block that renders an interactive console: recursive schema explorer, five kinds of authentication, live validation, generated code snippets, and a working Try It request runner.

Surface: import pipeline, editor plugin, auth flows, codegen · Scale: 28-file editor plugin · 3 spec versions · 4 snippet languages · Stack: TypeScript · React · Slate/Plate · Zod · Node.js

The brief: an API reference that lives inside a collaborative editor

Most API-documentation tools render an OpenAPI spec directly: the YAML is the source of truth, and the reference page is a projection of it. This platform works differently. Its core product is a collaborative rich-text editor, and everything on a page — paragraphs, tables, diagrams — is a typed node in an editor document. API reference had to play by the same rules: each endpoint becomes a document you can retitle, annotate, reorder, version, diff, and review, with the interactive console embedded as one block among ordinary prose.

That single constraint shaped the whole system. The spec couldn't stay a runtime dependency that pages render from; it had to be converted, once, into self-contained editor nodes — and then re-synced non-destructively when the spec changes. The result is a pipeline with two halves: a server-side importer that turns YAML into documents, and an editor plugin that turns one of those documents back into a working API console.

 spec.yaml / .json / .zip
      │  presigned upload → object storage → import job queue
      ▼
 worker         parse YAML · dereference every $ref · route by version (2.0 / 3.0 / 3.1)
      │
      ▼
 tree builder   one category per tag · one document per operation
      │         + models category · intro page · downloadable spec
      ▼
 editor documents — each endpoint is a void node with a typed, recursive payload
      │
      ▼
 editor plugin  parameters · credentials · live validation · Try It · code snippets

01 · Import — turning a spec into a tree of documents

Import starts in a drag-and-drop modal. Files go to object storage via presigned URLs, and the API enqueues a background job — the router only validates (file extensions, a 4 MB spec limit, a rate limit of 20 import jobs per user per 30 minutes) and never parses. A worker picks the job up, pulls the files back from storage, and hands them to the importer.

The importer parses YAML or JSON, then runs the spec through swagger-parser's dereference() so that every $ref — including refs across files inside a zipped multi-file spec, resolved through a custom in-memory resolver — is expanded before any conversion happens. A version check on the swagger/openapi field routes the document to one of three converters: Swagger 2.0, OpenAPI 3.0, or OpenAPI 3.1 — separate modules of 588, 807, and 878 lines, because the three formats genuinely disagree about bodies, servers, and nullability.

The shape of the output

The converter walks paths × methods and emits a document tree: a top-level category named after info.title, one sub-category per tag, one document per operation. Alongside the endpoints it can generate a Models category (one document per component schema), an Introduction page rendered from info.description, and a link to download the original spec file. Each endpoint document's body is editor-value JSON whose centerpiece is a single void node carrying the entire operation as data — method, URL, servers, parameters, request bodies, responses, security schemes.

Re-import without wreckage

Specs change, and users annotate the generated docs — so re-import can't be delete-and-recreate. Every operation document carries a stable identity: a SHA of METHOD-path (and schema-name for models). On re-sync the importer builds a map of existing hashes, regenerates the tree, and reuses document IDs where hashes match — updating in place — while documents whose operations vanished from the spec are collected as "dangling" and removed. Three entry points drive re-sync: the in-app re-import flow, a public API endpoint teams call from CI (the settings screen renders the exact curl command), and git-integration auto-sync that re-imports when the spec changes in a connected repository.

Decision. Resolve everything at import time. Dereferencing $refs once on the server means documents are self-contained: versioning, diffing, PDF export, and offline rendering all work without ever touching the original spec again.

02 · Data model — one recursive type, end to end

JSON Schema is recursive — objects contain properties, properties are schemas, arrays have item schemas, unions hold alternative schemas. Instead of flattening that on import, the node's payload keeps the recursion. A single parameter type describes a path parameter, a header, a deeply nested body field, and a security scheme alike, and it's defined as a recursive Zod schema (z.lazy()) so imported data is validated, not just cast:

// Abridged — the real type carries ~40 fields.
export const ApiRequestParameterSchema: z.ZodType<Parameter> = z.lazy(() =>
  z.object({
    name: z.string(),
    kind: z.enum(['required', 'optional', 'returned']).optional(),
    type: ParameterTypeSchema.optional(),          // string · number · object · array …
    schema: z.array(ApiRequestParameterSchema).optional(),   // object children
    complexType: z.enum(['oneOf', 'anyOf', 'allOf']).optional(),
    complexItems: z.array(ApiRequestParameterSchema).optional(), // union branches
    arrayOfSchemaObjects:
      z.array(z.array(ApiRequestParameterSchema)).optional(),    // user-added array rows
    oauth2Flows: OAuth2FlowsSchema.optional(),
    // …format, enum, pattern, minimum, maximum, multipleOf, uniqueItems …
  })
)

Three fields carry the interesting structure. schema is plain nesting — an object's children. complexType/complexItems preserve oneOf/anyOf/allOf unions as data, so the UI can offer the choice instead of silently picking a branch. And arrayOfSchemaObjects is a list of clones of an array's item schema — one clone per row the reader has added while composing a request body.

The same recursive shape flows through the whole system: the importer produces it, Zod validates it at the document boundary, the React component walks it to render, and the snippet generator walks it again to build example payloads. There is no translation layer to drift out of sync.

03 · Rendering — a component that calls itself

The console renders inside the editor as a void element — the rich-text engine treats it as an atomic block while React owns everything inside. The parameter tree is drawn by one component, OpenApiParameter, which renders a row (name, type, required marker, nullable badge, input) and then renders itself for every child schema, every selected union branch, and every array row:

{parameterSchema.map((child, index) => (
  <OpenApiParameter
    parameter={child}
    path={[...path, ...schemaPathSegment, index]}  // address of this node
    isRecursive
    onValueChange={onValueChange}
    …
  />
))}

Union types get a dropdown listing each branch by name; picking one swaps the subtree being rendered and resets descendant values. allOf is handled differently — its branches are merged into one flattened field list, matching its intersection semantics. Array-of-object bodies render each row as a full recursive subtree with add/remove controls; adding a row deep-clones the item schema so every row edits independently.

The renderer also curates. An Accept header whose enum has exactly one value is noise and gets hidden; an Authorization header is hidden whenever a declared security scheme already covers it, so readers never see the same credential asked for twice.

04 · State — path-addressed edits, regenerated snippets

Every input in the tree reports changes as value + path, where the path is the address accumulated during recursion — ['bodyDataParameters', 2, 'complexItems', 0, 'schema', 4] reads as "body parameter #2 → first union branch → fifth field." A reducer holds the working copy, and a single helper walks the path to the target node and mutates a cloned tree:

const target = getNodeAtPath({ root: request, path })
if (!target) return false
target.value = requestParamData.value

Edits are debounced at 200 ms, and each settled change triggers a full regeneration of the code examples. Snippets — cURL, JavaScript, Python, Ruby — are produced client-side by a ~1,850-line codegen package built on a forked Postman code generator: the recursive parameter tree is folded into an example payload (required fields and anything the reader typed), assembled into a request object, and rendered per language. The generator loads via dynamic import() so none of it weighs down the editor bundle until an API block actually appears.

URLs are live too. Multi-server specs render a server picker; templated server variables ({region}.api.example.com) become inputs or enum dropdowns resolved against their defaults; and path parameters substitute into the visible URL as the reader types — the un-resolved server URL is kept in a ref so substitution is always recomputed from the template, never from an already-substituted string.

Decision. Regenerate, don't patch. Snippets are rebuilt whole from current state on every change. No incremental string surgery, no drift between the form and the code a reader copies.

One escape hatch: specs that ship hand-written samples via the x-codeSamples vendor extension are respected — the block shows the author's snippets verbatim and stands its own generator (and the Try It button) down.

05 · Auth — five schemes, two OAuth flows, zero secrets in the document

Security schemes from the spec render as a credentials panel with a badge per scheme: HTTP Basic, HTTP Bearer, API key (header, query, or cookie), OAuth 2.0, and OpenID Connect, plus a legacy bearer fallback. Basic auth shows separate username/password fields with a live preview of the exact Authorization: Basic … header being base64-assembled. Simple schemes are one input. OAuth2 is where it gets interesting.

OAuth2 in the browser, done properly

The panel reads the spec's declared flows and supports the two that OAuth 2.1 keeps: Authorization Code with PKCE and Client Credentials. Implicit and Resource-Owner-Password are deliberately unsupported — a spec declaring only those gets an explanatory notice rather than a silently broken button.

The authorization-code flow runs entirely client-side. Clicking Authorize generates a random state (CSRF check) and a PKCE verifier/challenge pair, then opens the provider's authorize URL in a popup:

const expectedState = generateRandomId()            // echoed back, must match
const codeVerifier  = generateCodeVerifier()        // 32 random bytes, base64url
const codeChallenge = await deriveCodeChallenge(codeVerifier)  // S256

authorizeUrl.searchParams.set('code_challenge', codeChallenge)
authorizeUrl.searchParams.set('code_challenge_method', 'S256')
authorizeUrl.searchParams.set('state', expectedState)

A dedicated callback route catches the redirect, posts code and state back to the opener via postMessage (origin-checked), and closes itself. The opener verifies the state, exchanges the code for a token — sending the PKCE verifier so the provider can bind the exchange to the same client that started it — and drops the token into the credential field. A 500 ms poll detects the reader closing the popup mid-flow and surfaces "Authorization cancelled" instead of hanging. Scopes declared in the spec appear in a multi-select, with operation-required scopes pre-selected. And because token endpoints frequently reject cross-origin requests, failures are diagnosed in the error message itself — naming CORS as the likely cause and what the provider must allow.

Decision. Credential values never enter the editor document. Tokens persist in browser storage, keyed per docs space — the collaborative document that teammates sync, diff, and publish never carries a secret.

06 · Try It — running the request

The Try It button reuses the exact request the snippets describe: the same options-builder assembles URL, query string, headers, and body from the parameter tree, so what the reader runs is what the reader read. By default the request is relayed through the platform's backend — a deliberate proxy, because arbitrary customer APIs rarely allow browser calls from a docs domain. Spaces that have whitelisted their docs can flip a per-space setting to call the API directly from the browser instead; in that mode the client also strips request bodies from GET/HEAD calls, which fetch() rejects.

The response lands in a status-labeled result panel. When a call fails without a body, the block falls back to the spec's own documented response for that status code — the reference material doubles as the error explanation.

07 · Validation — the spec's constraints, checked as you type

Every constraint the spec declares becomes a live-checked chip on the input: 15 kinds in all — required, length and numeric bounds, exclusive bounds, pattern, multipleOf, array item counts and uniqueness, plus type and format checks against 13 recognized formats (email, uuid, date-time, int64, byte…). Each chip is tri-state: valid, invalid, or neutral when the field is empty — an empty optional field isn't an error.

The small print mattered more than the list:

  • Version semantics. exclusiveMinimum is a boolean modifier of minimum in OpenAPI 3.0 but a standalone number in 3.1. The validator accepts both and resolves the actual bound per version.
  • Float safety. multipleOf compares against an epsilon (1e-10) rather than trusting % on floats.
  • Hostile input. Spec-supplied regex patterns are length-capped before compiling, and invalid patterns degrade to neutral rather than throwing mid-keystroke.
  • Real ranges. int32/int64 check actual integer bounds, not just "is a number."

Rich text from the spec — operation and parameter descriptions, which OpenAPI allows as HTML — renders through an XSS filter everywhere it appears.

08 · By the numbers

ComponentMeasure
Editor plugin (console UI)28 files · ~5,700 lines
Spec-version converters2.0: 588 · 3.0: 807 · 3.1: 878 lines
Import hub module~5,300 lines
Snippet-generator package~1,850 lines · 4 languages
Validation15 constraint kinds · 13 formats
Authentication5 scheme types · 2 live OAuth2 flows
Edit feedback200 ms debounce → full snippet regen
Import guardrails4 MB spec limit · 20 jobs / 30 min

09 · Retrospective — decisions that held up

  • Documents, not projections. Converting the spec into editor nodes — instead of rendering from it at runtime — bought every editor feature for free: versioning, review workflows, diffing, exports, inline prose around endpoints.
  • One recursive type everywhere. Importer, validator, renderer, and codegen all walk the same shape. The recursion in the UI isn't an implementation trick; it's the domain model showing through.
  • Content-addressed re-sync. Hashing METHOD-path as document identity made re-import idempotent: update in place, delete only what disappeared, keep the IDs that user annotations and links point at.
  • Follow OAuth 2.1, not 2.0. Dropping implicit and password flows — with an honest notice — was the rare feature-removal that made the product more trustworthy.
  • Secrets stay out of the document. The moment credentials live in browser storage instead of the synced document, an entire class of leak scenarios disappears.

All figures are measured directly from the codebase at the time of writing. Identifying details — product name, package names, internal constants — have been altered or omitted. Companion pieces: An AI agent that rewrites your documentation, Git-style branching for documents, and Do the task once. Get the step-by-step guide.