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.
exclusiveMinimumis a boolean modifier ofminimumin OpenAPI 3.0 but a standalone number in 3.1. The validator accepts both and resolves the actual bound per version. - Float safety.
multipleOfcompares 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/int64check 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
| Component | Measure |
|---|---|
| Editor plugin (console UI) | 28 files · ~5,700 lines |
| Spec-version converters | 2.0: 588 · 3.0: 807 · 3.1: 878 lines |
| Import hub module | ~5,300 lines |
| Snippet-generator package | ~1,850 lines · 4 languages |
| Validation | 15 constraint kinds · 13 formats |
| Authentication | 5 scheme types · 2 live OAuth2 flows |
| Edit feedback | 200 ms debounce → full snippet regen |
| Import guardrails | 4 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-pathas 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.