There is something wonderfully meta about this post. The REST API I am about to describe is the same API that was used to upload this article to the blog. An AI agent — Claude — wrote the content, called the endpoint, and created the draft. I reviewed it in the admin UI and hit publish.
The CMS from the previous post gave me a browser-based admin interface. But I wanted programmatic access too. Specifically, I wanted Claude Code to be able to create, update, and manage blog posts without me opening a browser at all.
The blog backend already uses AppSync GraphQL for all data operations. So why not just give Claude the GraphQL endpoint?
Three reasons:
GraphQL requires Cognito tokens. Getting a Cognito JWT involves a multi-step authentication flow. An LLM agent calling curl does not want to manage token refresh, SRP challenges, or session cookies.
GraphQL is verbose. A simple "create post" operation requires a mutation string with typed variables, field selections, and proper formatting. REST is POST /api/posts/ with a JSON body.
Security boundary. I wanted a simple bearer token that I could rotate independently of Cognito credentials. The REST API acts as a gateway: it validates the bearer token, acquires a Cognito JWT server-side, and forwards the operation to AppSync.
Claude (curl) → /api/posts/* (bearer token) → Server-side Cognito auth → AppSync → DynamoDB + S3
The REST API is a thin translation layer. It handles authentication, maps HTTP verbs to GraphQL operations, and returns clean JSON responses. The actual data operations still go through AppSync — the REST endpoints just make them accessible via simple HTTP calls.
To call AppSync with write permissions, the server needs a Cognito JWT. I created a dedicated service account — a Cognito user that exists solely for programmatic access:
aws cognito-idp admin-create-user \
--user-pool-id eu-west-2_xxxxx \
--username claude-service@domain.com \
--user-attributes Name=email,Value=claude-service@domain.com \
--message-action SUPPRESS
The server-side auth utility (lib/service-auth.ts) uses the USER_PASSWORD_AUTH flow to acquire a JWT, caches it in memory, and auto-refreshes before expiry. Every API route call goes through this utility to get a valid token before making the AppSync request.
All write operations require Authorization: Bearer <token>. Read operations are public.
GET /api/posts/Returns all posts (published and drafts) sorted by date. Public access, no auth required.
GET /api/posts/{slug}/Returns a single post with its full markdown content. Public access.
POST /api/posts/Creates a new post. Accepts title, slug, date, tags, status, excerpt, description, and content. Defaults to draft status. Auto-calculates reading time from content length (200 words per minute).
PUT /api/posts/{slug}/Partial update — only include the fields you want to change. Useful for publishing a draft ({"status": "published"}), updating content, or fixing a typo in the excerpt.
DELETE /api/posts/{slug}/Removes the post from DynamoDB and deletes its content and media from S3.
A REST API is only useful if the consumer knows how to call it. For an LLM agent, that means a clear, comprehensive reference document. I wrote docs/BLOG_API.md — a single file containing everything Claude needs to manage posts:
This document is Claude's instruction manual. When I start a conversation about writing a blog post, Claude reads this file and knows exactly how to create the draft, upload media, and manage the post lifecycle.
The bearer token is stored in .env.local — never committed to the repository, never exposed to the browser. It is a shared secret between the agent and the server.
The Cognito service account credentials also stay server-side. The client (Claude) never sees or needs Cognito tokens. This separation means I can rotate the bearer token independently, revoke it without affecting the admin UI, and add rate limiting if needed.
All posts created via the API default to draft status. Publishing is an explicit action — either through the admin UI or a separate PUT call. This gives me a review step before anything goes live.
This blog post was created programmatically. Claude drafted the content based on the development plan, formatted it as markdown with YAML frontmatter, and called POST /api/posts/ to create it as a draft. I reviewed it in the admin UI, made edits, and published.
The entire content pipeline — from idea to published post — can now be driven by an AI agent with nothing more than curl and a bearer token.
With the blog backend fully programmable, I turned my attention back to the frontend. The site had a functional layout, but the responsive behaviour was inconsistent — cards would grow wider at certain breakpoints before shrinking again. It was time to build a proper CSS architecture.
That story is in Taming Responsive Layout with CSS Design Tokens and Fluid Scaling.
This post is part of a series documenting the development of arkadiuszkulpa.co.uk.