From 13ce631f53e304798589b9674b6a9419f8da93d4 Mon Sep 17 00:00:00 2001
From: Roman Marshevskyi
Date: Thu, 27 Aug 2026 12:42:29 +0300
Subject: [PATCH 1/4] docs(blog): add client generator intro blog post as a
React page
- Add /blog/agent-friendly-sdks as a React page with inline hero diagram
and a reviewer-style CTA card
- Support react-frontmatter for .page.tsx blog posts in the theme plugin
- Add blog-recent-posts shared data (top 4) and a shared RecentPosts
component that excludes the currently open post; localize the BlogPost
template to use it
- Add api-descriptions:openapi and api-lifecycle:sdks blog categories
Co-Authored-By: Claude Fable 5
---
@theme/components/Blog/RecentPosts.tsx | 52 ++
@theme/plugin.js | 32 +-
@theme/templates/BlogPost.tsx | 64 +-
@theme/utils/blog-post.js | 34 +-
blog/agent-friendly-sdks.page.tsx | 949 +++++++++++++++++++++++++
blog/images/cta-eclipse.svg | 16 +
blog/metadata/blog-metadata.yaml | 8 +
7 files changed, 1131 insertions(+), 24 deletions(-)
create mode 100644 @theme/components/Blog/RecentPosts.tsx
create mode 100644 blog/agent-friendly-sdks.page.tsx
create mode 100644 blog/images/cta-eclipse.svg
diff --git a/@theme/components/Blog/RecentPosts.tsx b/@theme/components/Blog/RecentPosts.tsx
new file mode 100644
index 000000000..fa1c99e12
--- /dev/null
+++ b/@theme/components/Blog/RecentPosts.tsx
@@ -0,0 +1,52 @@
+import * as React from 'react';
+import styled from 'styled-components';
+
+import { useThemeHooks } from '@redocly/theme/core/hooks';
+
+import { ArticleCard } from '@redocly/marketing-pages/components/Blog/RecentPosts.js';
+import { H2Title } from '@redocly/marketing-pages/components/TypographyElements/TypographyElements.js';
+
+type RecentPost = { slug: string; title: string; description?: string };
+
+// Same layout as marketing-pages RecentPosts, but reads the deeper 'blog-recent-posts'
+// shared data and filters out the post it renders on, so a post never lists itself.
+export function RecentPosts({ currentSlug }: { currentSlug?: string }) {
+ // @ts-ignore
+ const { usePageSharedData } = useThemeHooks();
+ const recentPosts = usePageSharedData('blog-recent-posts') ?? [];
+
+ const posts = recentPosts.filter((post) => post.slug !== currentSlug).slice(0, 3);
+
+ if (posts.length === 0) {
+ return null;
+ }
+
+ return (
+ <>
+
+ Latest from our blog
+
+
+ {posts.map((post) => (
+
+ ))}
+
+ >
+ );
+}
+
+const RecentPostsGrid = styled.div`
+ display: grid;
+ grid-template-columns: 1fr;
+ grid-gap: 5rem;
+ justify-items: center;
+
+ @media screen and (min-width: 900px) {
+ grid-template-columns: 1fr 1fr 1fr;
+ }
+`;
diff --git a/@theme/plugin.js b/@theme/plugin.js
index 5f3484125..4180912b0 100644
--- a/@theme/plugin.js
+++ b/@theme/plugin.js
@@ -7,6 +7,7 @@ const ABOUT_SLUG = '/about/';
const BLOG_METADATA_PATH = 'blog/metadata/blog-metadata.yaml';
const LATEST_POSTS_SHARED_DATA_ID = 'blog-latest-posts';
+const RECENT_POSTS_SHARED_DATA_ID = 'blog-recent-posts';
const ALL_POSTS_SHARED_DATA_ID = 'blog-posts';
function __dirname(url) {
@@ -26,11 +27,11 @@ export default function themePlugin() {
// Register preview route for the editor iframe
const previewTemplateId = actions.createTemplate(
'preview-template',
- fromCurrentDir(import.meta.url, './preview.route.tsx')
+ fromCurrentDir(import.meta.url, './preview.route.tsx'),
);
const blogTemplateId = actions.createTemplate(
- 'blog-template',
- fromCurrentDir(import.meta.url, './blog.page.tsx')
+ 'blog-template',
+ fromCurrentDir(import.meta.url, './blog.page.tsx'),
);
actions.addRoute({
excludeFromSidebar: true,
@@ -58,7 +59,7 @@ export default function themePlugin() {
templateId: blogTemplateId,
hasClientRoutes: true,
});
-
+
const metadataContentRecord = await context.cache.load(BLOG_METADATA_PATH, 'yaml');
const categories = metadataContentRecord.data.categories || [];
@@ -84,10 +85,11 @@ export default function themePlugin() {
// Existing blog data processing
const postRoutes = actions
.getAllRoutes()
- .filter((route) =>
- route.slug.startsWith(BLOG_SLUG) &&
- route.slug !== BLOG_SLUG &&
- !route.slug.startsWith('/blog/category/')
+ .filter(
+ (route) =>
+ route.slug.startsWith(BLOG_SLUG) &&
+ route.slug !== BLOG_SLUG &&
+ !route.slug.startsWith('/blog/category/'),
);
const categoryRoutes = actions
@@ -98,8 +100,14 @@ export default function themePlugin() {
const latestPosts = postsData.posts.slice(0, 3);
+ // Top 4 posts, so a post page can exclude itself and still show 3 recent posts
+ const recentPosts = postsData.posts
+ .slice(0, 4)
+ .map(({ slug, title, description }) => ({ slug, title, description }));
+
// Create shared data for blog pages
await actions.createSharedData(LATEST_POSTS_SHARED_DATA_ID, latestPosts);
+ await actions.createSharedData(RECENT_POSTS_SHARED_DATA_ID, recentPosts);
await actions.createSharedData(ALL_POSTS_SHARED_DATA_ID, postsData);
// Add latest posts shared data to all blog posts and update metadata
@@ -110,11 +118,17 @@ export default function themePlugin() {
LATEST_POSTS_SHARED_DATA_ID,
);
+ actions.addRouteSharedData(
+ post.slug,
+ RECENT_POSTS_SHARED_DATA_ID,
+ RECENT_POSTS_SHARED_DATA_ID,
+ );
+
const postRoute = actions.getRouteBySlug(post.slug);
postRoute.metadata = { ...postRoute.metadata, ...post };
}
-
+
// Add all posts shared data to category routes
for (const categoryRoute of categoryRoutes) {
actions.addRouteSharedData(
diff --git a/@theme/templates/BlogPost.tsx b/@theme/templates/BlogPost.tsx
index cde0630f7..5e4db51f6 100644
--- a/@theme/templates/BlogPost.tsx
+++ b/@theme/templates/BlogPost.tsx
@@ -1,3 +1,63 @@
-import Page from '@redocly/marketing-pages/templates/BlogPost.js';
+import React from 'react';
+import styled from 'styled-components';
-export default Page;
\ No newline at end of file
+import type { Post } from '@redocly/marketing-pages/components/Blog/types.js';
+
+import { useThemeHooks } from '@redocly/theme/core/hooks';
+import { Markdown } from '@redocly/theme/components/Markdown/Markdown';
+import PostInfo from '@redocly/marketing-pages/components/Blog/PostInfo.js';
+import { MediaBox } from '@redocly/marketing-pages/components/PositionItems/MediaBox.js';
+import { Box } from '@redocly/marketing-pages/ui/Box.js';
+
+import { RecentPosts } from '../components/Blog/RecentPosts';
+
+// Local version of @redocly/marketing-pages/templates/BlogPost.js — the only
+// difference is the RecentPosts section, which excludes the currently open post.
+export default function BlogPost(props: { children?: React.ReactNode }) {
+ const { usePageProps } = useThemeHooks();
+ const pageProps = usePageProps();
+
+ const { publishedDate, author, categories, title, image, slug } = pageProps.metadata as Post & {
+ slug?: string;
+ };
+
+ return (
+
+
+
+
+ {props.children}
+
+
+
+
+
+
+
+
+ );
+}
+
+const BlogMediaBox = styled.div`
+ margin-left: auto;
+ margin-right: auto;
+ max-width: calc(90vw);
+
+ @media screen and (min-width: 900px) {
+ max-width: 800px;
+ }
+`;
+
+const PageWrapper = styled.div`
+ position: relative;
+ overflow: hidden;
+`;
diff --git a/@theme/utils/blog-post.js b/@theme/utils/blog-post.js
index 8ed216162..563b97973 100644
--- a/@theme/utils/blog-post.js
+++ b/@theme/utils/blog-post.js
@@ -10,11 +10,19 @@ export const buildAndSortBlogPosts = async (postRoutes, context, outdir) => {
const metadata = await transformMetadata(metadataContentRecord.data, context.fs.cwd, outdir);
for (const route of postRoutes) {
- const {
- data: { content, frontmatter },
- } = await context.cache.load(route.fsPath, 'markdown-frontmatter');
+ // React blog posts export `frontmatter`; markdown posts use YAML frontmatter
+ const isReactPage = /\.page\.tsx?$/.test(route.fsPath);
+ const { data } = await context.cache.load(
+ route.fsPath,
+ isReactPage ? 'react-frontmatter' : 'markdown-frontmatter',
+ );
+ const frontmatter = isReactPage ? data : data?.frontmatter;
- if (frontmatter?.ignore === true || (await context.isPathIgnored(route.fsPath))) {
+ if (
+ (isReactPage && !frontmatter) ||
+ frontmatter?.ignore === true ||
+ (await context.isPathIgnored(route.fsPath))
+ ) {
continue;
}
@@ -26,15 +34,15 @@ export const buildAndSortBlogPosts = async (postRoutes, context, outdir) => {
.map((categoryId) => {
const categoryData = metadata.categories.get(categoryId);
if (!categoryData) return null;
-
+
if (categoryData.category && categoryData.subcategory) {
- return categoryData;
+ return categoryData;
} else {
- return {
+ return {
category: {
- id: categoryData.id,
- label: categoryData.label
- }
+ id: categoryData.id,
+ label: categoryData.label,
+ },
};
}
})
@@ -72,12 +80,12 @@ async function transformMetadata(metadata, cwd, outdir) {
categories.set(fullId, {
category: {
id: category.id,
- label: category.label
+ label: category.label,
},
subcategory: {
id: subcategory.id,
- label: subcategory.label
- }
+ label: subcategory.label,
+ },
});
}
}
diff --git a/blog/agent-friendly-sdks.page.tsx b/blog/agent-friendly-sdks.page.tsx
new file mode 100644
index 000000000..557d64847
--- /dev/null
+++ b/blog/agent-friendly-sdks.page.tsx
@@ -0,0 +1,949 @@
+import React from 'react';
+import styled from 'styled-components';
+
+import { useThemeHooks } from '@redocly/theme/core/hooks';
+import { Markdown } from '@redocly/theme/components/Markdown/Markdown';
+
+import type { Post } from '@redocly/marketing-pages/components/Blog/types.js';
+import PostInfo from '@redocly/marketing-pages/components/Blog/PostInfo.js';
+import { MediaBox } from '@redocly/marketing-pages/components/PositionItems/MediaBox.js';
+import { Box } from '@redocly/marketing-pages/ui/Box.js';
+
+import { RecentPosts } from '../@theme/components/Blog/RecentPosts';
+
+import ctaEclipse from './images/cta-eclipse.svg';
+
+export const frontmatter = {
+ title: 'Open-source, agent-friendly SDKs and tooling from OpenAPI description',
+ description:
+ 'Meet redocly generate-client: one OpenAPI description becomes typed SDKs in TypeScript, Python, Go, and PHP — plus validation schemas, query hooks, test mocks, a CLI, and docs. Open source, zero runtime dependencies, and built for AI agents.',
+ seo: {
+ title: 'Open-source, agent-friendly SDKs and tooling from OpenAPI description',
+ description:
+ 'Meet redocly generate-client: one OpenAPI description becomes typed SDKs in TypeScript, Python, Go, and PHP — plus validation schemas, query hooks, test mocks, a CLI, and docs. Open source, zero runtime dependencies, and built for AI agents.',
+ },
+ author: 'roman-marshevskyi',
+ publishedDate: '2026-08-27',
+ categories: ['redocly:redocly-cli', 'api-descriptions:openapi', 'api-lifecycle:sdks'],
+};
+
+export default function AgentFriendlySdksPost() {
+ const { usePageProps } = useThemeHooks();
+ const pageProps = usePageProps();
+
+ const { publishedDate, author, categories, title, slug } = (pageProps.metadata ?? {}) as Post & {
+ slug?: string;
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ One OpenAPI description, one command, and every consumer of your API gets a typed,
+ dependency-free artifact that regenerates instead of drifting.
+
+
+
+
+
+ As agents become part of engineering teams, more of your API calls are written by one.
+ Agents hallucinate endpoints, invent response fields, and hand-write API code you then
+ review line by line. Generated code is the cheapest, safest code an agent can ship, so
+ we built a generator that treats the agent as a first-class user.
+
+
+
+ Meet generate-client: a new command in the{' '}
+ Redocly CLI, powered by a new
+ package,{' '}
+
+ @redocly/client-generator
+
+ , that turns one OpenAPI description into typed SDKs in{' '}
+ TypeScript, Python, Go, and PHP, plus validation schemas, TanStack
+ Query and SWR hooks, test mocks, a ready-to-run command-line interface,
+ and reference docs for all of it. Both the command and the package are open source
+ (MIT), and everything they generate is yours outright.
+
+
+
+ The SDKs are fully featured with zero runtime dependencies: auth,
+ retries, middleware, pagination iterators, typed Server-Sent Events, query-string
+ serialization, and multipart uploads, all built on web-standard fetch,{' '}
+ AbortController, and URLSearchParams, emitted as code that
+ imports nothing. The API code your agent used to hallucinate becomes one deterministic
+ command, and the compiler becomes its fact-checker: operation ids, parameters, and
+ response fields are literal types, so a wrong call fails tsc with the exact
+ operation named.
+
+
+
Up and running in three steps
+
+
+
+ 1
+
+ Start from your API description
+
+ The one you already have: OpenAPI 3.0, 3.1, 3.2, or Swagger 2.0.
+
+
+
+ One description. One command. Every time your API changes.
+
+
The features you'd otherwise hand-write
+
+
+ Types are a third of the problem. The behavior is what teams hand-write around generated
+ types, and where drift starts. The generated client includes it:
+
+
+
+
+
+ Auth from your securitySchemes
+
+ : bearer, basic, and API keys in header, query, or cookie, each sent only where an
+ operation's security requires it. Credentials can be async token
+ providers, resolved on every request, so refresh flows need no extra code. Every
+ client instance carries its own.
+
+
+ Pagination: declare your pagination convention once (cursor, offset,
+ page, or Link header) and the iterators appear on the operation itself:{' '}
+ listOrders.pages(), listOrders.items(), typed, abortable,
+ with duplicate-cursor loop detection. Delete your pagination loops.
+
+
+ Opt-in, abort-aware retries: exponential backoff, jitter,{' '}
+ Retry-After, idempotent-only by default, and a custom{' '}
+ retryOn predicate.
+
+
+ Typed Server-Sent Events: an operation whose 2xx is{' '}
+ text/event-stream becomes a typed async iterator with automatic
+ reconnection, payloads typed from OpenAPI 3.2's itemSchema.
+
+
+ Composable middleware: onRequest,{' '}
+ onResponse, and onError, with operation ids, paths, and tags
+ visible to it as literal types.
+
+
+ The fiddly details, handled: query parameters serialized exactly as
+ the description declares, file uploads from a plain typed object, per-request
+ timeouts, and idempotency keys that make retries safe.
+
+
+ Two error models: exceptions by default, or a typed{' '}
+ {'{ data, error }'} result if you prefer returns over throws.
+
+
+
+
+ And it's strict on your behalf: a call with an argument the operation doesn't declare
+ fails before the request leaves the process, with an error that names the operation and
+ says where the argument belongs.
+
+
+
+ It reads OpenAPI 3.0, 3.1, and 3.2, plus Swagger 2.0{' '}
+ (normalized to 3.x before generation).
+
+
+
Skills first
+
+
+ Every part of this tool assumes an agent will operate it, and each of those decisions
+ helps the humans just as much:
+
+
+
+
+ The design ships as agent skills. Every generator carries its own
+ design document, and ejecting a generator drops it into your repo as a skill (
+ {'.claude/skills/-generator/'}) beside the authoring guide. An
+ agent asked to change generated output loads the rules first and edits the generator,
+ not the output.
+
+
+ A discoverable surface instead of prose. The generated CLI answers{' '}
+ --help with its commands and {'schema '} with one
+ operation's whole contract as JSON: method, path, parameters with types, request and
+ response schemas. An agent learns a real API in two commands.
+
+
+ Feedback an agent can act on. Strict types plus runtime
+ unknown-argument errors name the operation and say where the argument belongs.
+
+
+ Deterministic ground truth. The generated mocks are seeded and
+ offline, so tests an agent writes reproduce exactly, with no live API in the loop
+ teaching it wrong lessons.
+
+
+ Regeneration over hand-editing. The client is machine-owned and
+ rebuilt from the description; the generator is human-owned and ejectable. That split
+ tells an agent exactly which file it is allowed to change.
+
+
+
+
+ The instruction we ship our own agents is one paragraph:{' '}
+
+ never hand-write HTTP code for our APIs; regenerate the client and import the
+ functions, and a wrong call fails the build.
+
+
+
+
One description, every consumer
+
+
+ The vocabulary is simple: you select generators in one list, each
+ generator emits an artifact, and each artifact serves a different
+ consumer of your API. The SDK is one kind of artifact; here is the whole list, produced
+ from one parse of your description in one command:
+
+
+
+
+
+
+
Generator
+
Artifact
+
Consumer
+
+
+
+
+
+ typescript (default), python, go,{' '}
+ php
+
+
the full typed client, in that language
+
calling your API from any stack
+
+
+
+ zod
+
+
Zod schemas + validation middleware
+
runtime contract checks
+
+
+
+ tanstack-query, swr
+
+
query and mutation factories, hooks
+
React, Vue, Svelte, Solid data fetching
+
+
+
+ mock
+
+
MSW v2 handlers + typed data factories
+
tests and demos, offline and deterministic
+
+
+
+ transformers
+
+
+ Date converters
+
+
+ ISO strings → Date, paired with --date-type Date
+
+
+
+
+ cli
+
+
a bin-ready command-line interface
+
scripts, CI, agents
+
+
+
your own
+
anything
+
the long tail
+
+
+
+
+
+
+ Every language SDK carries the same behavior, each as a single self-contained file:{' '}
+ httpx for Python, the standard library for Go, the curl extension for PHP.
+ And names resolve once: listOrders is the operation in the description, the
+ function in every SDK, and the CLI command, so one identifier greps across your whole
+ stack.
+
+
+
+ Docs are one flag: add --docs and every selected generator writes a
+ reference page beside its output. The docs regenerate with the code, so they cannot
+ drift from it.
+
+
+
And if you disagree with a built-in, take it
+
+
+ When a tool gets something wrong for you, the traditional move is to fork it, and a fork
+ is a life sentence: you maintain the whole project from that day on, and upstream fixes
+ stop reaching you. Eject gives you the ownership without the fork:
+
+ That copies the built-in generator into your repository as{' '}
+ TypeScript source you own: a folder with one readable file per stage
+ (naming, types, models, operations, pagination, client). It wires your config to it,
+ and, unmodified, it produces byte-identical output. We verify that byte-identity in our
+ test suite. Later versions merge into your copy file by file with --update.
+ The generator's design document arrives with it as an{' '}
+ agent skill in your repo, and the skill is yours to manage: edit it to
+ state your house rules (naming, headers, error style, whatever the built-in got wrong
+ for you), and your AI agent reads the skill first and changes the ejected generator to
+ match. You maintain a short design document; the agent maintains the code to it.
+
+
+
Yours to shape
+
+
+
+ Call style: grouped inputs by default; --args-style flat{' '}
+ merges them into one object when an operation's inputs can't collide.
+
+
+ Output layout: one single file (default), or{' '}
+ split with schema types in a sibling module.
+
+
+ Runtime placement: inlined into the client by default for a truly
+ single-file artifact, or --runtime module to write the runtime as real,
+ readable files beside it, shared between clients.
+
+
+ No build step, if you want none: with --import-ext ts,
+ the generated client, the zod module, and the CLI run as they are under plain Node
+ 22.18+, which strips the types itself.
+
+
+ Configuration: CLI flags or a client block in{' '}
+ redocly.yaml, with per-API overrides for monorepos that generate several
+ clients from one config.
+
+
+
+
Proven on ourselves first
+
+
+ We didn't design this in the abstract: Redocly's own platform runs on this generator:
+ four internal APIs, hundreds of operations, an in-house codegen deleted in the process,
+ and much of the migration executed by an AI agent working against the generated client.
+ That migration found real bugs our tests had missed, and it's the subject of the next
+ post.
+
+
+
+ One caveat, stated plainly: the command is still experimental, flags and output may
+ change, so pin your CLI version. The code it generates is strict-TypeScript clean,
+ exhaustively tested, and already carrying Redocly's production traffic.
+
+
+
+
+
+
+
+
+
+ Try it on your own API
+
+ One command, no account, runs entirely on your machine.
+
+
+
+
+
@@ -123,10 +132,11 @@ export default function AgentFriendlySdksPost() {
No account, no config required. Flags or a redocly.yaml{' '}
client block, your choice.
-
+
@@ -137,28 +147,18 @@ export default function AgentFriendlySdksPost() {
Every operation is a typed function; every name comes from the description.
-