Automating Multi‑Platform Content Publishing: Fixing Bluesky JSON Validation and Keeping Metadata in Sync
TL;DR: I updated the content‑automation pipeline to generate a schema‑compliant Bluesky JSON payload and synchronized the metadata.json flags after each successful publish. The changes eliminate runtime validation errors and give us a reliable source‑of‑truth for what has been generated.
The Problem
Our weekly “content‑automation” job creates markdown articles for Medium and Substack, then builds platform‑specific payloads for Bluesky. The last run (2026‑10‑14) failed with the following error from the Bluesky validation endpoint:
POST https://api.bsky.app/xrpc/com.atproto.repo.createRecord
Response 400: {"error":"InvalidRequest","message":"JSON schema validation failed: required property 'text' missing"}
The script was reading content/2026/10/13/VS/bluesky_es.json, but the generated file was missing the required text field and contained an extra type value that the API rejects. At the same time, metadata.json still reported "medium_generated": false even though the Medium markdown files had been written, causing our CI checks to think the publish step was incomplete.
What I Tried First
My first attempt was to patch the JSON after it was written, adding a text property manually:
// scripts/generate_bluesky_payload.js (initial version)
const payload = JSON.parse(fs.readFileSync(srcPath, "utf8"));
payload.text = payload.text || ""; // quick fix
fs.writeFileSync(destPath, JSON.stringify(payload, null, 2));
I also added a post‑publish step that toggled the flags in metadata.json:
// scripts/update_metadata.js (initial version)
const meta = JSON.parse(fs.readFileSync("content/2026/10/14/content-automation/metadata.json"));
meta.medium_generated = true;
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
Both patches compiled, but the API still rejected the payload because the type field in the JSON array ("avance" for Spanish, "progress" for English) is not part of the allowed schema. Moreover, the manual text injection left the content empty, resulting in blank posts on Bluesky.
The Implementation
1. Refactor the Bluesky payload generator
The generator now builds the payload from a typed model instead of copying raw JSON. I introduced src/models/blueskyPost.js:
// src/models/blueskyPost.js
class BlueskyPost {
/**
* @param {string} language 'en' | 'es'
* @param {string} headline Title of the post
* @param {string} body Full markdown content
*/
constructor(language, headline, body) {
this.$type = "app.bsky.feed.post";
this.lang = language;
this.text = `${headline}\n\n${body}`;
this.createdAt = new Date().toISOString();
}
toJSON() {
// Only keep properties required by the API
const { $type, lang, text, createdAt } = this;
return { $type, lang, text, createdAt };
}
}
module.exports = BlueskyPost;
The generator script (scripts/generate_bluesky_payload.js) now imports this model:
// scripts/generate_bluesky_payload.js
const fs = require("fs");
const path = require("path");
const BlueskyPost = require("../src/models/blueskyPost");
// Helper to read markdown source
function readMarkdown(date, locale) {
const mdPath = path.join(
__dirname,
`../content/${date}/VS/${locale === "en" ? "medium_en.md" : "medium_es.md"}`
);
return fs.readFileSync(mdPath, "utf8");
}
// Build payload for both locales
function buildPayload(date) {
const enBody = readMarkdown(date, "en");
const esBody = readMarkdown(date, "es");
const enPost = new BlueskyPost("en", "Weekly Build Log – Receipts, Keep‑Alive, and Automation", enBody);
const esPost = new BlueskyPost("es", "Esta semana en content‑automation – keep‑alive, recibos y automatización", esBody);
return [enPost.toJSON(), esPost.toJSON()];
}
// Write JSON files that match the API schema
function writePayload(date) {
const payload = buildPayload(date);
const outDir = path.join(__dirname, `../content/${date}/VS`);
fs.mkdirSync(outDir, { recursive: true });
// English payload
fs.writeFileSync(
path.join(outDir, "bluesky_en.json"),
JSON.stringify([payload[0]], null, 2)
);
// Spanish payload
fs.writeFileSync(
path.join(outDir, "bluesky_es.json"),
JSON.stringify([payload[1]], null, 2)
);
}
// Run for the target date
const targetDate = "2026/10/13";
writePayload(targetDate);
console.log("✅ Bluesky payload generated");
Key changes:
-
Explicit schema – The
BlueskyPostclass guarantees$type,lang,text, andcreatedAtare present. -
No stray
typefield – The previoustype: "avance"/"progress"entries were removed. - Single source of truth – Both English and Spanish posts pull the same markdown files used for Medium/Substack, preventing drift.
2. Update the Git diff for the new JSON files
The commit that added the corrected payloads (c15cdc88) now contains clean JSON:
// content/2026/10/14/content-automation/bluesky_en.json
[
{
"$type": "app.bsky.feed.post",
"lang": "en",
"text": "Weekly Build Log – Receipts, Keep‑Alive, and Automation\n\nI woke up on a Tuesday morning ...",
"createdAt": "2026-10-14T07:05:12.000Z"
}
]
// content/2026/10/14/content-automation/bluesky_es.json
[
{
"$type": "app.bsky.feed.post",
"lang": "es",
"text": "Esta semana en content‑automation – keep‑alive, recibos y automatización\n\nCuando me desperté ...",
"createdAt": "2026-10-14T07:05:12.000Z"
}
]
3. Synchronize metadata flags automatically
Instead of a separate script, I embedded the flag update into the main pipeline (scripts/publish_all.js):
js
// scripts/publish_all.js
const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");
function updateMetadata(flags) {
const metaPath = path.join(
__dirname,
"..",
"content/2026/10/14/content-automation/metadata.json"
);
const meta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
Object.assign(meta, flags);
---
*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.*
*Repo: `zaerohell/content-automation` · 2026-10-15*
\#playadev #buildinpublic
Top comments (0)