diff --git a/connector/src/brandkit.js b/connector/src/brandkit.js
new file mode 100644
index 0000000..2745ec5
--- /dev/null
+++ b/connector/src/brandkit.js
@@ -0,0 +1,71 @@
+// Brand kit per client: saved hashtag sets, approved phrases/CTAs, words to avoid.
+// checkCaption() flags problems before a post goes for approval (shared by the dashboard, the queue and the MCP tools).
+import fs from "node:fs";
+import path from "node:path";
+import { PROJECT_ROOT } from "./env.js";
+
+const FILE = process.env.QUORLA_BRANDKIT_FILE || path.join(PROJECT_ROOT, "data", "brandkit.json");
+const CLIENTS = path.join(PROJECT_ROOT, "clients");
+fs.mkdirSync(path.dirname(FILE), { recursive: true });
+const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } };
+const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
+
+// Phrases that read as AI-written or generic (project writing rules).
+const GENERIC = ["elevate your", "unlock", "in today's fast-paced world", "look no further", "game-changer", "game changer", "take it to the next level", "dive into", "seamless", "revolutionize", "embark on"];
+// White-label: the agency must never appear in a client's public post.
+const AGENCY_WORDS = ["metatroncube", "metatron cube", "quorla"];
+const LIMITS = { instagram: 2200, facebook: 63206, tiktok: 2200, youtube: 5000, gbp: 1500, linkedin: 3000, threads: 500, pinterest: 500, reddit: 40000 };
+
+function checkClient(slug) {
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(String(slug || "")) || !fs.existsSync(path.join(CLIENTS, slug))) throw err("Unknown client.", 404);
+ return slug;
+}
+const tagList = (v) => [...new Set(String(Array.isArray(v) ? v.join(" ") : v || "").split(/[\s,]+/).map((t) => t.trim()).filter(Boolean).map((t) => `#${t.replace(/^#+/, "")}`).filter((t) => /^#[\p{L}\p{N}_]{1,60}$/u.test(t)))];
+const lines = (v) => (Array.isArray(v) ? v : String(v || "").split(/\n/)).map((x) => String(x).trim()).filter(Boolean).slice(0, 200);
+
+export function get(client) {
+ checkClient(client);
+ const k = read()[client] || {};
+ return { hashtagSets: k.hashtagSets || [], phrases: k.phrases || [], avoid: k.avoid || [], notes: k.notes || "", updatedAt: k.updatedAt || null, updatedBy: k.updatedBy || null };
+}
+
+export function save(client, patch, by = "team") {
+ checkClient(client);
+ const db = read();
+ const cur = get(client);
+ const next = {
+ hashtagSets: patch.hashtagSets !== undefined ? (patch.hashtagSets || []).slice(0, 30).map((s) => ({ name: String(s.name || "").trim().slice(0, 60) || "Set", tags: tagList(s.tags).slice(0, 30) })).filter((s) => s.tags.length) : cur.hashtagSets,
+ phrases: patch.phrases !== undefined ? lines(patch.phrases).map((x) => x.slice(0, 300)) : cur.phrases,
+ avoid: patch.avoid !== undefined ? lines(patch.avoid).map((x) => x.slice(0, 80)) : cur.avoid,
+ notes: patch.notes !== undefined ? String(patch.notes || "").slice(0, 3000) : cur.notes,
+ updatedAt: new Date().toISOString(), updatedBy: by,
+ };
+ db[client] = next;
+ const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(db, null, 1)); fs.renameSync(tmp, FILE);
+ return next;
+}
+
+const EMOJI = /\p{Extended_Pictographic}/gu;
+/**
+ * Problems in a caption for this client: { level: "bad" | "warn", text }.
+ * bad = must fix (agency named in a client post, over the platform limit); warn = worth a second look.
+ */
+export function checkCaption(client, caption, { platforms = [] } = {}) {
+ const text = String(caption || "");
+ const low = text.toLowerCase();
+ const out = [];
+ let kit = { avoid: [] };
+ try { kit = get(client); } catch { /* unknown client: generic checks only */ }
+ // The agency's own account (its client name contains the agency name) may of course mention itself.
+ let ownName = "";
+ try { ownName = JSON.parse(fs.readFileSync(path.join(CLIENTS, client, "client.json"), "utf8")).name || ""; } catch { /* no client.json */ }
+ const isAgencyItself = AGENCY_WORDS.some((w) => `${ownName} ${client}`.toLowerCase().replace(/[-_]/g, " ").includes(w));
+ if (!isAgencyItself) for (const w of AGENCY_WORDS) if (low.includes(w)) out.push({ level: "bad", text: `Mentions “${w}”. Client posts must never name the agency.` });
+ for (const p of platforms) if (LIMITS[p] && text.length > LIMITS[p]) out.push({ level: "bad", text: `Too long for ${p}: ${text.length} of ${LIMITS[p]} characters.` });
+ for (const w of kit.avoid) if (w && low.includes(w.toLowerCase())) out.push({ level: "warn", text: `Uses “${w}”, which is on this client's avoid list.` });
+ for (const g of GENERIC) if (low.includes(g)) out.push({ level: "warn", text: `“${g}” sounds generic. Say it the way the client would.` });
+ const tags = text.match(/#[\p{L}\p{N}_]+/gu) || [];
+ if (tags.length > 10) out.push({ level: "warn", text: `${tags.length} hashtags. 3–8 relevant ones usually do better.` });
+ if ((text.match(EMOJI) || []).length > 8) out.push({ level: "warn", text: "Lots of emoji. Keep a few that add meaning." });
+ return out;
+}
diff --git a/connector/src/schedule.js b/connector/src/schedule.js
index c0b551e..2a35c03 100644
--- a/connector/src/schedule.js
+++ b/connector/src/schedule.js
@@ -9,6 +9,7 @@ import { PROJECT_ROOT } from "./env.js";
import * as meta from "./meta.js";
import * as extra from "./platforms/index.js";
import * as quota from "./platforms/quota.js";
+import * as brandkit from "./brandkit.js";
const META_PLATFORMS = ["instagram", "facebook"];
export const ALL_PLATFORMS = [...META_PLATFORMS, ...extra.PLATFORM_IDS];
@@ -90,6 +91,19 @@ function quotaAccount(client, platform) {
}
/** Warnings (not errors) when a day's planned posts exceed a platform's limit; extra posts then wait for the reset. */
+/** Brand-kit and writing checks on every caption a post will use. Naming the agency is refused outright. */
+function brandWarnings(post) {
+ const captions = [[null, post.caption], ["facebook", post.fbCaption], ...Object.entries(post.options || {}).map(([pl, o]) => [pl, o?.caption])].filter(([, c]) => c);
+ const out = [];
+ for (const [pl, c] of captions) {
+ for (const w of brandkit.checkCaption(post.client, c, { platforms: pl ? [pl] : post.platforms })) {
+ if (/never name the agency/.test(w.text)) throw err(`${pl ? `${pl} caption: ` : ""}${w.text}`);
+ out.push(`${pl ? `${pl} caption: ` : ""}${w.text}`);
+ }
+ }
+ return [...new Set(out)];
+}
+
function quotaWarnings(post, items) {
const at = new Date(post.publishAt).getTime();
const out = [];
@@ -137,7 +151,7 @@ export function createPost({ client, platforms = ["instagram"], type = "IMAGE",
results: {}, errors: {},
};
const items = read();
- post.warnings = quotaWarnings(post, items);
+ post.warnings = [...quotaWarnings(post, items), ...brandWarnings(post)];
items.push(post); write(items);
emit("draft_created", post);
return post;
@@ -151,7 +165,7 @@ export function updatePost(id, patch, by = "team") {
for (const pl of p.platforms) if (!META_PLATFORMS.includes(pl)) extra.validate(pl, p.client, p);
if (patch.localTime) { const t = zonedToUtc(patch.localTime, p.timezone); p.publishAt = t.toISOString(); p.localTime = formatInTz(t, p.timezone); }
p.status = "draft"; p.updatedBy = by; p.updatedAt = new Date().toISOString();
- p.warnings = quotaWarnings(p, items);
+ p.warnings = [...quotaWarnings(p, items), ...brandWarnings(p)];
// Changed after the client looked at it: ask them again.
if (p.clientReview && p.clientReview.status !== "pending") p.clientReview = { ...p.clientReview, status: "pending", previous: p.clientReview.status, requestedAt: p.updatedAt, note: "", by: null };
write(items); return p;
diff --git a/connector/src/server.js b/connector/src/server.js
index 6efb982..82053c7 100644
--- a/connector/src/server.js
+++ b/connector/src/server.js
@@ -12,6 +12,7 @@ import * as sched from "./schedule.js";
import * as lib from "./library.js";
import * as plan from "./plan.js";
import * as competitors from "./competitors.js";
+import * as brandkit from "./brandkit.js";
import * as extra from "./platforms/index.js";
const server = new McpServer({ name: "social-manager", version: "0.1.0" });
@@ -214,4 +215,11 @@ server.tool("competitor_add", "Start tracking an Instagram competitor for a clie
{ client: z.string(), username: z.string(), note: z.string().default("") },
wrap(async ({ client, username, note }) => competitors.add(client, username, { by: "agent", source: "suggested", note })));
+server.tool("brand_kit", "A client's saved hashtag sets, approved phrases/CTAs and words to avoid. Read it before writing captions for that client.",
+ { client: z.string() }, wrap(async ({ client }) => brandkit.get(client)));
+
+server.tool("caption_check", "Check a caption against the client's brand kit and our writing rules before scheduling (agency name, avoid list, generic phrases, hashtag count, platform length).",
+ { client: z.string(), caption: z.string(), platforms: z.array(z.string()).default([]) },
+ wrap(async ({ client, caption, platforms }) => brandkit.checkCaption(client, caption, { platforms })));
+
await server.connect(new StdioServerTransport());
diff --git a/connector/test/brandkit.test.mjs b/connector/test/brandkit.test.mjs
new file mode 100644
index 0000000..ee24266
--- /dev/null
+++ b/connector/test/brandkit.test.mjs
@@ -0,0 +1,24 @@
+// Brand kit and caption checks (offline, temp file).
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+process.env.QUORLA_BRANDKIT_FILE = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "quorla-bk-")), "bk.json");
+const bk = await import("../src/brandkit.js");
+const { PROJECT_ROOT } = await import("../src/env.js");
+const client = fs.readdirSync(path.join(PROJECT_ROOT, "clients")).find((d) => !d.startsWith("_") && !/metatron/i.test(d) && fs.existsSync(path.join(PROJECT_ROOT, "clients", d, "client.json")));
+
+test("brand kit: saves clean hashtag sets and lists; caption checks", { skip: !client && "no client folder" }, () => {
+ const k = bk.save(client, { hashtagSets: [{ name: "Local", tags: "waterloo, #kitchener #KW bad-tag!" }], phrases: "Order online\n\nBook a table", avoid: "cheap\nbest in town" });
+ assert.deepEqual(k.hashtagSets[0].tags, ["#waterloo", "#kitchener", "#KW"]);
+ assert.deepEqual(k.phrases, ["Order online", "Book a table"]);
+ const w = bk.checkCaption(client, "The best in town, made by Quorla. Unlock flavour! " + "#a ".repeat(12), { platforms: ["threads"] }).map((x) => x.level + ":" + x.text.slice(0, 25));
+ assert.ok(w.some((x) => x.startsWith("bad:Mentions “quorla”")));
+ assert.ok(w.some((x) => x.startsWith("warn:Uses “best in town”")));
+ assert.ok(w.some((x) => x.startsWith("warn:“unlock”")));
+ assert.ok(w.some((x) => x.startsWith("warn:12 hashtags")));
+ assert.equal(bk.checkCaption(client, "x".repeat(600), { platforms: ["threads"] })[0].level, "bad");
+ assert.deepEqual(bk.checkCaption(client, "Fresh focaccia at 8. First 20 people get a free cortado.", { platforms: ["instagram"] }), []);
+});
diff --git a/dashboard/server/index.js b/dashboard/server/index.js
index c5e577e..24e2c2c 100644
--- a/dashboard/server/index.js
+++ b/dashboard/server/index.js
@@ -21,6 +21,7 @@ import * as sched from "../../connector/src/schedule.js";
import * as lib from "../../connector/src/library.js";
import * as plan from "../../connector/src/plan.js";
import * as competitors from "../../connector/src/competitors.js";
+import * as brandkit from "../../connector/src/brandkit.js";
import * as extra from "../../connector/src/platforms/index.js";
import { publicBaseUrl, resolveMediaToken } from "../../connector/src/platforms/media.js";
import { DATA_DIR as _DD } from "./config.js";
@@ -333,6 +334,11 @@ app.put("/api/clients/:slug/monthly", requireAdmin, h(async (req) => ({ settings
app.post("/api/clients/:slug/monthly/build", h(async (req) => monthly.buildWithPdf(req.params.slug, String(req.body?.month || monthly.lastMonth()))));
app.post("/api/clients/:slug/monthly/email", requireAdmin, h(async (req) => monthly.emailReport(req.params.slug, String(req.body?.month || monthly.lastMonth()))));
+// ---------- brand kit: hashtag sets, phrases, words to avoid; caption checks ----------
+app.get("/api/brandkit/:client", h(async (req) => brandkit.get(req.params.client)));
+app.put("/api/brandkit/:client", h(async (req) => brandkit.save(req.params.client, req.body || {}, who(req))));
+app.post("/api/brandkit/:client/check", h(async (req) => ({ warnings: brandkit.checkCaption(req.params.client, req.body?.caption, { platforms: req.body?.platforms || [] }) })));
+
// ---------- notifications ----------
app.get("/api/notifications", h(async (req) => notify.listFor(req.user.id)));
app.post("/api/notifications/read", h(async (req) => { notify.markRead(req.user.id, Array.isArray(req.body?.ids) ? req.body.ids : null); return notify.listFor(req.user.id); }));
diff --git a/dashboard/web/src/pages/BrandKit.jsx b/dashboard/web/src/pages/BrandKit.jsx
new file mode 100644
index 0000000..44d6070
--- /dev/null
+++ b/dashboard/web/src/pages/BrandKit.jsx
@@ -0,0 +1,91 @@
+import { useEffect, useRef, useState } from "react";
+import { Plus, Save, Trash2 } from "lucide-react";
+import { api } from "../api.js";
+import { Button, ErrorNote, Field, Panel, Spinner, timeAgo } from "../ui.jsx";
+
+/** Client page tab: hashtag sets, approved phrases/CTAs, words to avoid, voice notes. */
+export default function BrandKit({ slug }) {
+ const [kit, setKit] = useState(null);
+ const [sets, setSets] = useState([]);
+ const [phrases, setPhrases] = useState("");
+ const [avoid, setAvoid] = useState("");
+ const [notes, setNotes] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [saved, setSaved] = useState(false);
+ const apply = (k) => { setKit(k); setSets(k.hashtagSets.map((s) => ({ name: s.name, tags: s.tags.join(" ") }))); setPhrases(k.phrases.join("\n")); setAvoid(k.avoid.join("\n")); setNotes(k.notes); };
+ useEffect(() => { api(`/api/brandkit/${slug}`).then(apply).catch(setError); }, [slug]);
+
+ const save = async () => {
+ setBusy(true); setError(null); setSaved(false);
+ try { apply(await api(`/api/brandkit/${slug}`, { method: "PUT", body: { hashtagSets: sets, phrases, avoid, notes } })); setSaved(true); } catch (e) { setError(e); }
+ setBusy(false);
+ };
+ if (!kit) return error ?