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: "
Content goes here...
" 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