When we decided ClarkCant needed an engineering blog, the lazy option was a static site generator and a folder of Markdown. I didn't want that, and not because I enjoy building things twice. A blog is a small enough product to test whether the ideas behind ClarkCant hold up outside a chat window.

So this blog borrows them. An article is a document with typed blocks, not a pile of HTML. The charts and diagrams are the exact widgets Clark draws in a conversation, each with a text alternative. Every article has a Markdown twin, and there's an llms.txt for machines. And an AI client can write here through MCP, with scopes you grant and can take back.

Fair warning: this post is about itself. Read the note on the right.

Who wrote this

An AI agent drafted this article in my voice from the two repositories, checked it against the blog's own publication schema, and it is saved through the blog's API by an agent.

An article is a document, not a page

Every article here is one JSON document with two complete locales, English and Vietnamese. Each locale has a title, a description and a list of blocks. There are 11 block types: text, heading, image, carousel, code, video, youtube, sandbox, survey, widget and layout. Text is plain text with blank lines between paragraphs. There's no raw HTML anywhere in the schema, so there's nothing for an author, human or agent, to inject. The page escapes everything on the way out.

The schema lives in src/lib/document.ts and does double duty. Saving a draft checks the shape: strict objects, block ids, sizes, valid HTTPS media URLs. Publishing checks more. Both languages must have a title, a description and content, and every widget's props are validated against the same contract ClarkCant uses. You can't publish an article that's half translated or a chart with a typo in its props. It just refuses, and tells you which block.

The blog by the numbers: 11 block types in the block catalog; 35 widget definitions in the widget contract generated from ClarkCant; 8 operations; 3 scopes; an OAuth access token lives 60 minutes; at most 100 revisions are kept per article.

Underlying data
[
  {
    "id": "blocks",
    "label": "Block types",
    "value": 11
  },
  {
    "id": "widgets",
    "label": "Widget definitions",
    "value": 35,
    "hint": "generated from ClarkCant"
  },
  {
    "id": "ops",
    "label": "Operations",
    "value": 8
  },
  {
    "id": "scopes",
    "label": "Scopes",
    "value": 3
  },
  {
    "id": "oauth",
    "label": "OAuth token lifetime",
    "value": 60,
    "unit": "min"
  },
  {
    "id": "history",
    "label": "Revisions kept",
    "value": 100,
    "hint": "per article, at most"
  }
]

Bilingual is a hard rule, not a nice-to-have. Most of the people I build with read Vietnamese first. Some readers of ClarkCant don't read it at all. Shipping only one would mean picking which group gets the real article.

The two versions don't have to be word for word. They share block ids and structure, but each should read like someone wrote it in that language. Including this one.

Same widgets as Clark

In ClarkCant, the model never writes UI. It picks a view from a catalog, and the host draws it with trusted renderers. This blog follows the same rule. An article doesn't contain chart code. It contains a widget block: a definition id like canvas.diagram@1, a version, props, optional rows of data, and a semantic text that says in words what the widget shows.

The renderers aren't copied. scripts/build-widgets.mjs bundles packages/conversation-client/src/public-widget.tsx straight from a ClarkCant checkout into one script, and generates the widget contract from packages/widget-catalog. Today that contract lists 35 definitions. The public entry is deliberately weaker than the one in the app: it lends a widget local view state only. No actions, no runtime connection, no approvals, no credentials. A widget on a blog has no business pressing buttons on your node.

Without JavaScript you still get the article. The semantic text is printed under every widget, and its rows sit inside a disclosure labelled Underlying data. The diagram below is a widget. If it doesn't load, the sentence under it is the same diagram in words.

Publishing flow of this blog. Three kinds of writer reach the same door: an author in Studio signed in with GitHub, an AI client over MCP with OAuth and PKCE, and the CLI or REST API with a token created in Studio. The door checks scopes: blog:read, blog:write and blog:publish. Behind it is one set of operations defined in operations.ts. save_article creates a new revision, using compare-and-swap on the expected revision and an idempotency key for retries; up to 100 revisions are kept as history. publish_article publishes English and Vietnamese together and checks every widget. A published article becomes an HTML page that works without JavaScript, where the shared widget renderer from ClarkCant draws widgets when JavaScript is on, plus a Markdown twin and entries in llms.txt and llms-full.txt. Readers and AI readers read all three.

Nodes follow src/lib/operations.ts, src/lib/content-service.ts and src/lib/public-routes.ts.

Every article has a Markdown twin

Add .md to any article URL and you get the same article as Markdown, built from the published revision only. Text stays text. A widget becomes its semantic description followed by its rows as JSON, so a model reading it gets the data, not a screenshot.

There's also /llms.txt, which lists every published article in both languages with links to the Markdown versions, and /llms-full.txt, which is all of it concatenated. RSS and the sitemap work the same way. Drafts never show up in any of them. Publishing is the only door.

I care about this more than about the design, honestly. More and more of the people reading an engineering blog are reading it through a model. If the only way to get the content is to render a page and scrape it, you've made the reader understand your architecture.

Agents write here through MCP

The blog has an MCP server at https://clarkcant.cc/mcp, over Streamable HTTP. It doesn't have its own logic. src/lib/operations.ts defines eight operations, each with a schema and the scope it needs, and the REST API, the CLI and the MCP server all call the same ones. The MCP server is built per request, for one principal, and it registers only the tools that principal's scopes allow.

Excerpt of src/lib/mcp.ts, lines 19 to 25. For each operation, the server skips it unless the principal's scopes include the operation's scope; otherwise it registers a tool with the operation's description and schema, marks read-scope operations and validate_article as read-only, marks unpublish_article as destructive, and returns either the result as JSON text and structured content or an error.

src/lib/mcp.ts, lines 19–25.

That loop is the whole permission model for tools, and I like it because it's boring. A client with blog:read can't call save_article. It doesn't get a polite refusal. The tool just isn't there.

Table of the blog's eight operations in src/lib/operations.ts, each with the scope it needs and what it does: list_articles (blog:read): List private editorial articles with pagination; get_article (blog:read): Read an article or a historical revision for editing; get_block_catalog (blog:read): Discover document and block schemas, including the public widget host contract; validate_article (blog:write): Validate both translations without saving; save_article (blog:write): Create or save a bilingual draft; existing IDs need expectedRevision; slug is immutable; publish_article (blog:publish): Publish or restore an existing bilingual revision; unpublish_article (blog:publish): Remove an article from public reading without deleting its history; article_history (blog:read): List up to 100 retained revisions for recovery.

Underlying data
[
  {
    "id": "list_articles",
    "name": "list_articles",
    "scope": "blog:read",
    "what": "List private editorial articles with pagination"
  },
  {
    "id": "get_article",
    "name": "get_article",
    "scope": "blog:read",
    "what": "Read an article or a historical revision for editing"
  },
  {
    "id": "get_block_catalog",
    "name": "get_block_catalog",
    "scope": "blog:read",
    "what": "Discover document and block schemas, including the public widget host contract"
  },
  {
    "id": "validate_article",
    "name": "validate_article",
    "scope": "blog:write",
    "what": "Validate both translations without saving"
  },
  {
    "id": "save_article",
    "name": "save_article",
    "scope": "blog:write",
    "what": "Create or save a bilingual draft; existing IDs need expectedRevision; slug is immutable"
  },
  {
    "id": "publish_article",
    "name": "publish_article",
    "scope": "blog:publish",
    "what": "Publish or restore an existing bilingual revision"
  },
  {
    "id": "unpublish_article",
    "name": "unpublish_article",
    "scope": "blog:publish",
    "what": "Remove an article from public reading without deleting its history"
  },
  {
    "id": "article_history",
    "name": "article_history",
    "scope": "blog:read",
    "what": "List up to 100 retained revisions for recovery"
  }
]
Descriptions from src/lib/operations.ts.

Getting a token works the way a careful client expects. The server publishes OAuth metadata at /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server, supports dynamic client registration, and requires PKCE with S256. You sign in with GitHub, and only invited members get in. The consent page lists exactly which scopes the client asked for. What you can grant depends on your role: an editor gets read and write, a publisher also gets publish. An OAuth access token lives for one hour.

For scripts there are API tokens, created in Studio under Connections & team, shown once, valid for 1 to 30 days, with the scopes you pick. One button revokes every non-session token and pending OAuth code for your account. And every request checks that you're still an active member, so removing someone ends their agent's access too.

One thing I can't claim yet. ChatGPT and Claude are the clients this was built for, and the docs say plainly that real account consent depends on the deployment's OAuth configuration and end-to-end testing. Protocol tests passing isn't the same as those clients working. I'd rather say that here than have you find out.

Agents make mistakes, so saves are careful

An agent retrying a request is normal. An agent overwriting your edit because it read the article five minutes ago is not. So save_article on an existing article requires expectedRevision. If someone saved in between, you get a 409, not a silent overwrite. Every save also carries an idempotency key. Send the same key with the same input and you get the original response back. Send the same key with different input and you get a 409 telling you the key was reused.

Every save, publish and unpublish writes an audit row. Up to 100 revisions are kept per article, and restoring one is just publishing an older revision. Unpublishing takes the article off the public site without deleting its history. Nothing an agent does here is irreversible by accident.

Or skip MCP and use the CLI

Not every agent speaks MCP, and not every human wants OAuth for a script. The repository ships a small CLI over the same API. It reads the token from CLARKCANT_BLOG_TOKEN or --api-key, refuses plain HTTP except on loopback, never stores credentials, prints JSON and exits non-zero on failure. These three lines are from the blog docs.

bash
corepack pnpm cli get_block_catalog --url https://clarkcant.cc
corepack pnpm cli list_articles --file query.json
corepack pnpm cli save_article --file article.json
From docs/blog.html in the clarkcant-web repository.

About this article

As the note at the top says, an agent wrote this. It read both repositories, the blog's code and ClarkCant's, wrote the English and Vietnamese versions in my voice, checked every widget against the publication schema, and the draft is saved through the blog's API. It wasn't asked to sell anything, and where it couldn't verify something, it said so instead of guessing.

That's the bet behind ClarkCant: conversation is the app, and the work behind it should be real, checked and reversible. A blog where an agent can write but can't skip validation, can't overwrite someone else's edit, and loses access the moment you revoke it is a small, concrete version of that bet.

Timeline, oldest first, Saigon time: 16 September 2026, first commit of the ClarkCant repository; 23 September, the official ClarkCant website; 24 September, bilingual developer docs for the API, MCP, WebSocket and CLI (pull request 4); 4 October, the blog with bilingual widget publishing and scoped authoring (pull request 89); 5 October, the blog page redesigned to match the landing page, on a branch not yet merged.

Commit dates from the clarkcant and clarkcant-web repositories.

The whole thing runs on a Cloudflare Worker with D1 for revisions, auth and surveys, and R2 for media. The blog itself landed on 4 October. Some of it will be wrong in ways I haven't noticed. Tell me.

Would you connect an AI client to your own blog?