Lúc quyết định ClarkCant cần một blog kỹ thuật, đường lười nhất là lấy một static site generator với một thư mục Markdown. Tui không muốn vậy, và không phải vì tui thích làm một việc hai lần. Blog là một sản phẩm đủ nhỏ để thử xem những ý tưởng đằng sau ClarkCant có còn đứng vững khi ra khỏi khung chat hay không.
Nên blog này mượn chúng. Một bài viết là document gồm các block có kiểu, không phải một đống HTML. Biểu đồ và sơ đồ chính là widget Clark vẽ trong cuộc hội thoại, cái nào cũng có phần mô tả bằng chữ. Bài nào cũng có bản Markdown song sinh, và có llms.txt cho máy đọc. Còn AI client thì viết bài ở đây được qua MCP, với scope bạn cấp và rút lại được.
Nói trước: bài này viết về chính nó. Đọc cái ghi chú bên cạnh nha.
Ai viết bài này
Một AI agent soạn bài này bằng giọng của tui từ hai repo, kiểm tra nó với chính schema xuất bản của blog, và bài được lưu qua API của blog bởi một agent.
Bài viết là document, không phải trang web
Mỗi bài ở đây là một document JSON với hai locale đầy đủ, tiếng Anh và tiếng Việt. Mỗi locale có tiêu đề, mô tả và một danh sách block. Có 11 loại block: text, heading, image, carousel, code, video, youtube, sandbox, survey, widget và layout. Text là chữ thuần, các đoạn cách nhau bằng một dòng trống. Trong schema không có chỗ nào cho HTML thô, nên người viết, dù là người hay agent, chẳng có gì để chèn vào. Trang web escape hết mọi thứ khi render.
Schema nằm ở src/lib/document.ts và làm hai việc. Lưu nháp thì kiểm tra hình dạng: object chặt, id của block, kích thước, URL media phải là HTTPS hợp lệ. Xuất bản thì kiểm tra nhiều hơn. Cả hai ngôn ngữ phải có tiêu đề, mô tả và nội dung, và props của mọi widget được kiểm tra theo đúng hợp đồng mà ClarkCant dùng. Bạn không xuất bản được một bài mới dịch một nửa, hay một biểu đồ gõ sai props. Nó từ chối thẳng và chỉ ra block nào sai.
Blog qua vài con số: 11 loại block trong block catalog; 35 định nghĩa widget trong hợp đồng widget sinh ra từ ClarkCant; 8 operation; 3 scope; token truy cập OAuth sống 60 phút; mỗi bài giữ tối đa 100 revision.
Dữ liệu
[
{
"id": "blocks",
"label": "Loại block",
"value": 11
},
{
"id": "widgets",
"label": "Định nghĩa widget",
"value": 35,
"hint": "sinh ra từ ClarkCant"
},
{
"id": "ops",
"label": "Operation",
"value": 8
},
{
"id": "scopes",
"label": "Scope",
"value": 3
},
{
"id": "oauth",
"label": "Thời hạn token OAuth",
"value": 60,
"unit": "phút"
},
{
"id": "history",
"label": "Revision được giữ",
"value": 100,
"hint": "tối đa, mỗi bài"
}
]Song ngữ là luật cứng, không phải món thêm cho vui. Phần lớn người tui làm cùng đọc tiếng Việt trước. Một số người đọc ClarkCant thì không đọc được tiếng Việt chút nào. Chỉ ra một bản nghĩa là phải chọn nhóm nào được đọc bài thật.
Hai bản không cần dịch từng chữ. Chúng dùng chung id và cấu trúc block, nhưng bản nào cũng phải đọc như người bản xứ viết. Kể cả bài này.
Widget giống hệt của Clark
Trong ClarkCant, model không bao giờ tự viết UI. Nó chọn một view trong catalog, còn host vẽ bằng renderer đáng tin. Blog này theo đúng luật đó. Bài viết không chứa code biểu đồ. Nó chứa một widget block: một definition id kiểu canvas.diagram@1, một version, props, vài dòng dữ liệu nếu cần, và một đoạn semantic nói bằng chữ widget đó thể hiện cái gì.
Renderer không phải bản copy. scripts/build-widgets.mjs bundle packages/conversation-client/src/public-widget.tsx thẳng từ một bản checkout ClarkCant thành một file script, và sinh hợp đồng widget từ packages/widget-catalog. Hiện hợp đồng đó có 35 định nghĩa. Entry công khai được cố ý làm yếu hơn bản trong app: nó chỉ cho widget view state cục bộ. Không action, không kết nối runtime, không phê duyệt, không credential. Widget nằm trên blog thì không có lý do gì để bấm nút trên node của bạn.
Không có JavaScript bạn vẫn đọc được bài. Đoạn semantic được in ngay dưới mỗi widget, còn dữ liệu nằm trong một mục gập có nhãn Underlying data. Sơ đồ dưới đây là một widget. Nếu nó không tải được, câu chữ bên dưới chính là sơ đồ đó viết thành lời.
Luồng xuất bản của blog này. Ba kiểu người viết cùng đi qua một cửa: tác giả trong Studio đăng nhập bằng GitHub, một AI client qua MCP với OAuth và PKCE, và CLI hoặc REST API với token tạo trong Studio. Cửa đó kiểm tra scope: blog:read, blog:write và blog:publish. Phía sau là một bộ operation duy nhất định nghĩa trong operations.ts. save_article tạo revision mới, so khớp revision mong đợi và dùng idempotency key cho lần thử lại; tối đa 100 revision được giữ làm lịch sử. publish_article xuất bản tiếng Anh và tiếng Việt cùng lúc và kiểm tra mọi widget. Bài đã xuất bản thành một trang HTML chạy được không cần JavaScript, nơi widget renderer dùng chung từ ClarkCant vẽ widget khi JavaScript bật, cùng một bản Markdown song sinh và các mục trong llms.txt và llms-full.txt. Người đọc và AI đọc dùng cả ba.
Bài nào cũng có bản Markdown song sinh
Thêm .md vào URL của bất kỳ bài nào là bạn có đúng bài đó dạng Markdown, dựng từ revision đã xuất bản và chỉ revision đó thôi. Chữ vẫn là chữ. Widget biến thành phần mô tả semantic, theo sau là dữ liệu dạng JSON, nên model đọc vào sẽ có dữ liệu chứ không phải ảnh chụp màn hình.
Còn có /llms.txt, liệt kê mọi bài đã xuất bản bằng cả hai thứ tiếng kèm link tới bản Markdown, và /llms-full.txt là tất cả nối lại thành một. RSS và sitemap cũng vậy. Bản nháp không bao giờ xuất hiện ở mấy chỗ đó. Xuất bản là cánh cửa duy nhất.
Nói thật, tui quan tâm chuyện này hơn cả phần thiết kế. Ngày càng nhiều người đọc blog kỹ thuật thông qua một model. Nếu cách duy nhất để lấy nội dung là render trang rồi cào về, thì bạn đang bắt người đọc hiểu kiến trúc của bạn.
Agent viết bài ở đây qua MCP
Blog có một MCP server ở https://clarkcant.cc/mcp, chạy Streamable HTTP. Nó không có logic riêng. src/lib/operations.ts định nghĩa tám operation, mỗi cái có schema và scope cần có, và REST API, CLI lẫn MCP server đều gọi đúng mấy operation đó. MCP server được dựng mới cho từng request, cho một principal, và chỉ đăng ký những tool mà scope của principal đó cho phép.
Trích đoạn src/lib/mcp.ts, dòng 19 tới 25. Với mỗi operation, server bỏ qua nếu scope của principal không chứa scope của operation; ngược lại nó đăng ký một tool với mô tả và schema của operation, đánh dấu operation thuộc scope đọc và validate_article là chỉ đọc, đánh dấu unpublish_article là có tính phá hủy, rồi trả về kết quả dạng JSON và structured content, hoặc trả về lỗi.
Vòng lặp đó là toàn bộ mô hình phân quyền cho tool, và tui thích nó vì nó nhàm. Client có blog:read thì không gọi được save_article. Nó không nhận lời từ chối lịch sự nào cả. Tool đơn giản là không tồn tại với nó.
Bảng tám operation của blog trong src/lib/operations.ts, mỗi dòng gồm tên, scope cần có và việc nó làm: list_articles (blog:read): Liệt kê bài viết nội bộ, có phân trang; get_article (blog:read): Đọc một bài hoặc một revision cũ để sửa; get_block_catalog (blog:read): Xem schema của document và block, gồm cả hợp đồng widget host công khai; validate_article (blog:write): Kiểm tra cả hai bản dịch mà không lưu; save_article (blog:write): Tạo hoặc lưu bản nháp song ngữ; bài có sẵn cần expectedRevision; slug không đổi được; publish_article (blog:publish): Xuất bản hoặc khôi phục một revision song ngữ có sẵn; unpublish_article (blog:publish): Gỡ bài khỏi trang công khai mà không xóa lịch sử; article_history (blog:read): Liệt kê tối đa 100 revision được giữ để khôi phục.
Dữ liệu
[
{
"id": "list_articles",
"name": "list_articles",
"scope": "blog:read",
"what": "Liệt kê bài viết nội bộ, có phân trang"
},
{
"id": "get_article",
"name": "get_article",
"scope": "blog:read",
"what": "Đọc một bài hoặc một revision cũ để sửa"
},
{
"id": "get_block_catalog",
"name": "get_block_catalog",
"scope": "blog:read",
"what": "Xem schema của document và block, gồm cả hợp đồng widget host công khai"
},
{
"id": "validate_article",
"name": "validate_article",
"scope": "blog:write",
"what": "Kiểm tra cả hai bản dịch mà không lưu"
},
{
"id": "save_article",
"name": "save_article",
"scope": "blog:write",
"what": "Tạo hoặc lưu bản nháp song ngữ; bài có sẵn cần expectedRevision; slug không đổi được"
},
{
"id": "publish_article",
"name": "publish_article",
"scope": "blog:publish",
"what": "Xuất bản hoặc khôi phục một revision song ngữ có sẵn"
},
{
"id": "unpublish_article",
"name": "unpublish_article",
"scope": "blog:publish",
"what": "Gỡ bài khỏi trang công khai mà không xóa lịch sử"
},
{
"id": "article_history",
"name": "article_history",
"scope": "blog:read",
"what": "Liệt kê tối đa 100 revision được giữ để khôi phục"
}
]Lấy token thì đúng kiểu một client cẩn thận mong đợi. Server công bố metadata OAuth ở /.well-known/oauth-protected-resource và /.well-known/oauth-authorization-server, hỗ trợ đăng ký client động, và bắt buộc PKCE với S256. Bạn đăng nhập bằng GitHub, và chỉ thành viên được mời mới vào được. Trang consent liệt kê chính xác scope mà client xin. Bạn cấp được gì tùy vào vai trò: editor có read và write, publisher có thêm publish. Token truy cập OAuth sống một tiếng.
Với script thì có API token, tạo trong Studio ở mục Connections & team, chỉ hiện một lần, hiệu lực từ 1 tới 30 ngày, với scope bạn chọn. Một nút bấm thu hồi mọi token không phải session và mọi mã OAuth đang chờ của tài khoản bạn. Và request nào cũng kiểm tra bạn còn là thành viên đang hoạt động, nên gỡ một người ra là agent của họ cũng mất quyền luôn.
Có một điều tui chưa dám khẳng định. ChatGPT và Claude là hai client mà cái này được làm ra để phục vụ, và tài liệu nói rõ: consent với tài khoản thật còn phụ thuộc cấu hình OAuth lúc deploy và kiểm thử đầu cuối. Test giao thức qua không có nghĩa là hai client đó chạy được. Tui thà nói ra ở đây còn hơn để bạn tự phát hiện.
Agent có lúc sai, nên việc lưu phải cẩn thận
Agent thử lại một request là chuyện bình thường. Agent ghi đè lên chỗ bạn vừa sửa vì nó đọc bài từ năm phút trước thì không. Nên save_article với bài có sẵn bắt buộc phải có expectedRevision. Nếu ai đó đã lưu ở giữa, bạn nhận 409 chứ không bị ghi đè trong im lặng. Mỗi lần lưu còn mang một idempotency key. Gửi lại cùng key với cùng nội dung thì nhận lại đúng response cũ. Cùng key mà nội dung khác thì nhận 409 báo key đã bị dùng lại.
Mỗi lần lưu, xuất bản hay gỡ bài đều ghi một dòng audit. Mỗi bài giữ tối đa 100 revision, và khôi phục chỉ đơn giản là xuất bản lại một revision cũ. Gỡ bài thì bài biến khỏi trang công khai nhưng lịch sử vẫn còn. Không có gì agent làm ở đây mà lỡ tay là mất luôn.
Hoặc bỏ MCP, dùng CLI
Không phải agent nào cũng nói MCP, và không phải ai cũng muốn đi OAuth chỉ để chạy một script. Repo có kèm một CLI nhỏ chạy trên cùng API đó. Nó đọc token từ CLARKCANT_BLOG_TOKEN hoặc --api-key, từ chối HTTP thường trừ khi chạy loopback, không bao giờ lưu credential, in ra JSON và thoát với mã khác 0 khi lỗi. Ba dòng dưới đây lấy từ tài liệu của blog.
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.jsonVề chính bài này
Như ghi chú ở đầu bài, một agent đã viết bài này. Nó đọc cả hai repo, code của blog và code của ClarkCant, viết bản tiếng Anh và tiếng Việt bằng giọng của tui, kiểm tra từng widget với schema xuất bản, và bản nháp được lưu qua API của blog. Nó không được giao nhiệm vụ quảng cáo gì cả, và chỗ nào không xác minh được thì nó nói ra chứ không đoán.
Đó là canh bạc đằng sau ClarkCant: cuộc hội thoại chính là ứng dụng, và phần việc phía sau phải thật, được kiểm tra và đảo ngược được. Một blog mà agent viết được nhưng không né được bước validate, không ghi đè được chỗ người khác vừa sửa, và mất quyền ngay khi bạn thu hồi, là một phiên bản nhỏ và cụ thể của canh bạc đó.
Dòng thời gian, cũ nhất trước, giờ Sài Gòn: 16/9/2026, commit đầu tiên của repo ClarkCant; 23/9, website chính thức của ClarkCant; 24/9, tài liệu developer song ngữ cho API, MCP, WebSocket và CLI (pull request 4); 4/10, blog với xuất bản widget song ngữ và quyền viết theo scope (pull request 89); 5/10, trang blog được làm lại theo landing page, trên một nhánh chưa merge.
Cả hệ thống chạy trên một Cloudflare Worker, D1 lo revision, auth và survey, R2 lo media. Blog mới có từ ngày 4 tháng 10. Chắc chắn có chỗ sai mà tui chưa thấy. Thấy thì nói tui nghe.