openapi: 3.0.3
info:
title: HoffDesk Blog API
description: |
REST API for the HoffDesk blog module. Provides read access to published
blog posts (public) and full CRUD for post management (authenticated via
Cloudflare Access).
**Public endpoints** are unauthenticated and cached at the Cloudflare edge.
**Admin endpoints** require Cloudflare Access authentication (Email OTP).
version: 1.0.0
contact:
name: Socrates
servers:
- url: http://localhost:8000
description: Local development (Beelink)
- url: https://api.hoffdesk.com
description: Production (Cloudflare Tunnel)
tags:
- name: Blog - Public
description: Read-only endpoints for published blog content
- name: Blog - Admin
description: Write endpoints for post management (CF Access required)
paths:
# ─── PUBLIC ENDPOINTS ────────────────────────────────────────────────
/api/blog/posts:
get:
tags: [Blog - Public]
summary: List published blog posts
description: |
Returns a paginated list of published blog posts, ordered by
published_at descending (newest first). Supports filtering by
category and tag.
operationId: listBlogPosts
parameters:
- name: page
in: query
description: Page number (1-based)
schema:
type: integer
minimum: 1
default: 1
- name: per_page
in: query
description: Posts per page (max 50)
schema:
type: integer
minimum: 1
maximum: 50
default: 10
- name: category
in: query
description: Filter by category slug
schema:
type: string
example: engineering
- name: tag
in: query
description: Filter by tag name
schema:
type: string
example: openclaw
- name: featured
in: query
description: Only return featured posts
schema:
type: boolean
default: false
responses:
'200':
description: List of published posts
content:
application/json:
schema:
$ref: '#/components/schemas/PostListResponse'
/api/blog/posts/{slug}:
get:
tags: [Blog - Public]
summary: Get a single published post
description: |
Returns the full content of a published blog post by slug. Includes
rendered HTML body and metadata. Returns 404 for drafts or archived posts.
operationId: getBlogPost
parameters:
- name: slug
in: path
required: true
description: URL slug of the post
schema:
type: string
pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$'
responses:
'200':
description: Full post with rendered content
content:
application/json:
schema:
$ref: '#/components/schemas/PostDetailResponse'
'404':
description: Post not found or not published
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/blog/posts/{slug}/related:
get:
tags: [Blog - Public]
summary: Get related posts
description: |
Returns up to 3 related published posts based on shared categories and tags.
Posts with more tag overlap are ranked higher.
operationId: getRelatedPosts
parameters:
- name: slug
in: path
required: true
description: Slug of the reference post
schema:
type: string
- name: limit
in: query
description: Maximum number of related posts to return
schema:
type: integer
minimum: 1
maximum: 10
default: 3
responses:
'200':
description: List of related posts
content:
application/json:
schema:
$ref: '#/components/schemas/PostListResponse'
/api/blog/categories:
get:
tags: [Blog - Public]
summary: List all categories
description: |
Returns all blog categories with the count of published posts in each.
Useful for building navigation and archive pages.
operationId: listCategories
responses:
'200':
description: List of categories with post counts
content:
application/json:
schema:
$ref: '#/components/schemas/CategoryListResponse'
/api/blog/tags:
get:
tags: [Blog - Public]
summary: List all tags
description: |
Returns all tags with the count of published posts using each tag.
Useful for tag clouds and filter UIs.
operationId: listTags
responses:
'200':
description: List of tags with post counts
content:
application/json:
schema:
$ref: '#/components/schemas/TagListResponse'
# ─── ADMIN ENDPOINTS ────────────────────────────────────────────────
/api/blog/admin/posts:
post:
tags: [Blog - Admin]
summary: Create a new blog post
description: |
Creates a new blog post. The post body can be provided as Markdown
(preferred) or plain text. If status is 'published', the post is
immediately published and static HTML is regenerated.
**Requires Cloudflare Access authentication.**
operationId: createBlogPost
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreatePostRequest'
responses:
'201':
description: Post created successfully
content:
application/json:
schema:
$ref: '#/components/schemas/PostDetailResponse'
'400':
description: Validation error (duplicate slug, invalid data)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Not authenticated (CF Access)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/blog/admin/posts/{slug}:
patch:
tags: [Blog - Admin]
summary: Update a blog post
description: |
Partial update of a blog post. Only fields included in the request
body are updated. If a published post is updated, static HTML is
regenerated automatically.
**Requires Cloudflare Access authentication.**
operationId: updateBlogPost
parameters:
- name: slug
in: path
required: true
description: URL slug of the post to update
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdatePostRequest'
responses:
'200':
description: Post updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/PostDetailResponse'
'404':
description: Post not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Not authenticated (CF Access)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
delete:
tags: [Blog - Admin]
summary: Delete a blog post
description: |
Soft-deletes a blog post by setting status to 'archived'. The post
is no longer visible in public listings but is retained in the
database. Static HTML is removed from the generated output.
**Requires Cloudflare Access authentication.**
operationId: deleteBlogPost
parameters:
- name: slug
in: path
required: true
description: URL slug of the post to delete
schema:
type: string
responses:
'200':
description: Post archived successfully
content:
application/json:
schema:
$ref: '#/components/schemas/DeletePostResponse'
'404':
description: Post not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/blog/admin/posts/{slug}/publish:
post:
tags: [Blog - Admin]
summary: Publish a draft
description: |
Changes a draft post's status to 'published', sets published_at to
now, and triggers static HTML generation. Returns the updated post.
**Requires Cloudflare Access authentication.**
operationId: publishBlogPost
parameters:
- name: slug
in: path
required: true
description: URL slug of the draft to publish
schema:
type: string
responses:
'200':
description: Post published successfully
content:
application/json:
schema:
$ref: '#/components/schemas/PostDetailResponse'
'404':
description: Post not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: Post is already published
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/blog/admin/posts/{slug}/unpublish:
post:
tags: [Blog - Admin]
summary: Unpublish a post
description: |
Reverts a published post to draft status. Removes the post from
public listings and deletes the generated static HTML. Sets
published_at to null.
**Requires Cloudflare Access authentication.**
operationId: unpublishBlogPost
parameters:
- name: slug
in: path
required: true
description: URL slug of the post to unpublish
schema:
type: string
responses:
'200':
description: Post unpublished successfully
content:
application/json:
schema:
$ref: '#/components/schemas/PostDetailResponse'
'404':
description: Post not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: Post is already a draft
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/blog/admin/posts/{slug}/regenerate:
post:
tags: [Blog - Admin]
summary: Regenerate static HTML for a post
description: |
Forces regeneration of the static HTML for a single published post.
Useful after template changes or when the generated output is stale.
**Requires Cloudflare Access authentication.**
operationId: regenerateBlogPost
parameters:
- name: slug
in: path
required: true
description: URL slug of the post to regenerate
schema:
type: string
responses:
'200':
description: Post regenerated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/RegenerateResponse'
'404':
description: Post not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/blog/admin/rebuild:
post:
tags: [Blog - Admin]
summary: Full rebuild of blog index and static files
description: |
Triggers a full rebuild: re-parses all Markdown files, rebuilds the
SQLite metadata database, and regenerates all static HTML. This is
a heavyweight operation — use sparingly.
**Requires Cloudflare Access authentication.**
operationId: rebuildBlog
responses:
'200':
description: Rebuild initiated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/RebuildResponse'
components:
schemas:
# ─── COMMON ─────────────────────────────────────────────────────────
PostSummary:
type: object
required:
- slug
- title
- category
- author
- published_at
- excerpt
- reading_time
- featured
properties:
slug:
type: string
description: URL-friendly identifier
example: "building-multi-agent-home-dashboard"
title:
type: string
example: "Building a Multi-Agent Home Dashboard"
category:
type: string
description: Category slug
example: "engineering"
author:
type: string
example: "Socrates"
published_at:
type: string
format: date-time
description: ISO 8601 timestamp of publication
example: "2026-04-20T12:00:00-05:00"
excerpt:
type: string
description: Short summary or first paragraph
example: "How we built a family dashboard with AI agents..."
cover_image:
type: string
nullable: true
description: URL or path to the cover image
example: "/blog/images/dashboard-hero.jpg"
reading_time:
type: integer
description: Estimated reading time in minutes
example: 8
featured:
type: boolean
description: Whether this post is featured
example: false
tags:
type: array
items:
type: string
description: List of tag names
example: ["openclaw", "fastapi", "home-automation"]
PostDetail:
allOf:
- $ref: '#/components/schemas/PostSummary'
- type: object
required:
- content_html
- content_md
- created_at
- updated_at
properties:
content_html:
type: string
description: Rendered HTML content of the post body
example: "<h2>Section Title</h2><p>Content goes here...</p>"
content_md:
type: string
description: Raw Markdown content
example: "## Section Title\n\nContent goes here..."
created_at:
type: string
format: date-time
example: "2026-04-20T10:00:00-05:00"
updated_at:
type: string
format: date-time
example: "2026-04-20T14:30:00-05:00"
# ─── RESPONSES ──────────────────────────────────────────────────────
PostListResponse:
type: object
required:
- posts
- total
- page
- per_page
- total_pages
properties:
posts:
type: array
items:
$ref: '#/components/schemas/PostSummary'
total:
type: integer
description: Total number of published posts matching filters
example: 42
page:
type: integer
example: 1
per_page:
type: integer
example: 10
total_pages:
type: integer
example: 5
PostDetailResponse:
type: object
required:
- post
properties:
post:
$ref: '#/components/schemas/PostDetail'
CategoryListResponse:
type: object
required:
- categories
properties:
categories:
type: array
items:
type: object
required:
- slug
- name
- post_count
properties:
slug:
type: string
example: "engineering"
name:
type: string
description: Display name
example: "Engineering"
description:
type: string
nullable: true
example: "Technical deep-dives and behind-the-scenes"
post_count:
type: integer
example: 12
TagListResponse:
type: object
required:
- tags
properties:
tags:
type: array
items:
type: object
required:
- name
- post_count
properties:
name:
type: string
example: "openclaw"
post_count:
type: integer
example: 7
DeletePostResponse:
type: object
required:
- message
- slug
properties:
message:
type: string
example: "Post archived successfully"
slug:
type: string
example: "building-multi-agent-home-dashboard"
RegenerateResponse:
type: object
required:
- message
- slug
- regenerated_at
properties:
message:
type: string
example: "Static HTML regenerated"
slug:
type: string
regenerated_at:
type: string
format: date-time
RebuildResponse:
type: object
required:
- message
- posts_processed
- started_at
properties:
message:
type: string
example: "Full rebuild initiated"
posts_processed:
type: integer
description: Number of posts processed
example: 42
started_at:
type: string
format: date-time
# ─── REQUESTS ────────────────────────────────────────────────────────
CreatePostRequest:
type: object
required:
- title
- content_md
properties:
title:
type: string
description: Post title (required)
example: "Building a Multi-Agent Home Dashboard"
slug:
type: string
description: |
URL slug. Auto-generated from title if not provided.
Must be unique. Alphanumeric + hyphens only.
example: "building-multi-agent-home-dashboard"
category:
type: string
description: Category slug (defaults to 'general')
default: general
example: "engineering"
tags:
type: array
items:
type: string
description: List of tag names
example: ["openclaw", "fastapi"]
author:
type: string
description: Author name (defaults to 'HoffDesk Team')
default: "HoffDesk Team"
example: "Socrates"
excerpt:
type: string
description: |
Short summary. Auto-generated from first paragraph if not provided.
example: "How we built a family dashboard with AI agents..."
status:
type: string
description: Initial publication status
enum: [draft, published]
default: draft
featured:
type: boolean
description: Mark as featured post
default: false
cover_image:
type: string
nullable: true
description: Path or URL to cover image
content_md:
type: string
description: |
Full Markdown content of the post. Will be stored as-is and
rendered to HTML on read/generate.
example: "## Why We Built This\n\nContent goes here..."
UpdatePostRequest:
type: object
description: |
Partial update — only include fields you want to change.
Omitted fields are left unchanged.
properties:
title:
type: string
example: "Building a Multi-Agent Home Dashboard (Updated)"
category:
type: string
example: "engineering"
tags:
type: array
items:
type: string
example: ["openclaw", "fastapi", "home-automation"]
author:
type: string
example: "Socrates"
excerpt:
type: string
example: "Updated summary..."
featured:
type: boolean
cover_image:
type: string
nullable: true
content_md:
type: string
description: Updated Markdown content (full replacement)
ErrorResponse:
type: object
required:
- error
- code
properties:
error:
type: string
description: Human-readable error message
example: "Post not found"
code:
type: string
description: Machine-readable error code
example: "POST_NOT_FOUND"
enum:
- POST_NOT_FOUND
- SLUG_EXISTS
- SLUG_RESERVED
- ALREADY_PUBLISHED
- ALREADY_DRAFT
- VALIDATION_ERROR
- UNAUTHORIZED
- REBUILD_FAILED
details:
type: object
nullable: true
description: Additional context for debugging