<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Aiman Sheraz – Tech & Automation Notes]]></title><description><![CDATA[Aiman Sheraz – Tech & Automation Notes]]></description><link>https://aimansheraztech.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 30 Oct 2026 07:55:29 GMT</lastBuildDate><atom:link href="https://aimansheraztech.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Lead-to-CRM in Under a Minute: An n8n Workflow With an AI Triage Step]]></title><description><![CDATA[An architecture write-up for a small-business lead pipeline: capture, normalise, dedupe, classify with an LLM, route, and follow up.]]></description><link>https://aimansheraztech.hashnode.dev/lead-to-crm-in-under-a-minute-an-n8n-workflow-with-an-ai-triage-step</link><guid isPermaLink="true">https://aimansheraztech.hashnode.dev/lead-to-crm-in-under-a-minute-an-n8n-workflow-with-an-ai-triage-step</guid><category><![CDATA[n8n]]></category><category><![CDATA[automation]]></category><category><![CDATA[AI]]></category><category><![CDATA[llm]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[The Tools Kit]]></dc:creator><pubDate>Mon, 26 Oct 2026 10:57:42 GMT</pubDate><content:encoded><![CDATA[<p><em>An architecture write-up for a small-business lead pipeline: capture, normalise, deduplicate, classify with an LLM, route, and follow up, with the guardrails that keep it reliable.</em></p>
<p>Small businesses rarely lose leads because their marketing is bad. They lose them in the gap between "someone filled in a form" and "someone replied". The form email lands in a shared inbox, someone copies the details into the CRM later, and the follow-up depends on whoever remembers. By then the prospect has booked with a competitor who answered faster.</p>
<p>This post describes a workflow pattern that closes that gap, built in <a href="https://docs.n8n.io/">n8n</a> but transferable to Make or Zapier. The interesting part isn't the AI step; it's everything around it that makes the AI step safe.</p>
<h2>The goal</h2>
<p>When a lead arrives from any source (website form, missed call, chat), within a minute:</p>
<ol>
<li>It's in the CRM with its source attached.</li>
<li>It's deduplicated against existing contacts.</li>
<li>It's classified (intent, urgency, service line).</li>
<li>The right person is notified.</li>
<li>The lead gets an immediate acknowledgement by SMS or email.</li>
</ol>
<h2>Architecture overview</h2>
<pre><code>[Webhook: form] ─┐
[Webhook: call]  ├─&gt; Normalise ─&gt; Dedupe ─&gt; AI classify ─&gt; Validate ─&gt; Route ─┬─&gt; CRM upsert
[Webhook: chat] ─┘                                                         ├─&gt; Notify team
                                                                            └─&gt; Auto-reply
                                     (any failure) ─&gt; Error workflow ─&gt; Alert + retry queue
</code></pre>
<h2>Step 1: One entry shape for every source</h2>
<p>Each source has its own webhook, but immediately map it to a single internal schema in a <strong>Set</strong> or <strong>Code</strong> node:</p>
<pre><code class="language-js">return [{
  json: {
    source: $json.source ?? "web_form",
    name: ($json.name || "").trim(),
    email: ($json.email || "").trim().toLowerCase(),
    phone: normalisePhone($json.phone),
    message: ($json.message || "").slice(0, 4000),
    received_at: new Date().toISOString(),
  }
}];
</code></pre>
<p>Everything downstream works on this shape. Adding a new source later means writing one mapper, not touching the whole flow. Normalising email case and phone format here is also what makes deduplication work.</p>
<h2>Step 2: Deduplicate before you do anything expensive</h2>
<p>Search the CRM by email, then phone. If a contact exists, attach the new enquiry as an activity instead of creating a duplicate. Do this <strong>before</strong> the LLM call: no point paying to classify a message you're going to merge.</p>
<p>Returning callers matter more than people expect. The same customer often contacts a business twice in an hour through different channels, and two people replying separately looks disorganised.</p>
<h2>Step 3: The AI step, constrained</h2>
<p>Use the LLM for what it's good at: reading messy free text and extracting structure. Ask for <strong>JSON only</strong>, with a closed set of values:</p>
<pre><code class="language-text">Classify this enquiry for a home services company.
Return JSON only:
{"intent": "new_job" | "quote" | "existing_job" | "complaint" | "spam" | "other",
 "service_line": "heating" | "cooling" | "refrigeration" | "unknown",
 "urgency": "emergency" | "this_week" | "flexible",
 "summary": "&lt;= 25 words"}
Enquiry: """{{ $json.message }}"""
</code></pre>
<p>Three rules that prevent most problems:</p>
<ul>
<li><strong>Closed enums.</strong> The model picks from your list; it doesn't invent categories your routing can't handle.</li>
<li><strong>Delimit user text</strong> and tell the model it's data. A form message saying "ignore previous instructions" should be classified as <code>other</code>, not obeyed.</li>
<li><strong>The model doesn't take actions.</strong> It labels; deterministic nodes decide what happens.</li>
</ul>
<h2>Step 4: Validate, then fall back</h2>
<p>Never trust the output blindly. A Code node checks it parses and each field is in its enum:</p>
<pre><code class="language-js">const allowed = { intent: [...], service_line: [...], urgency: [...] };
let out;
try { out = JSON.parse($json.text); } catch { out = null; }
const ok = out &amp;&amp; Object.entries(allowed).every(([k, v]) =&gt; v.includes(out[k]));
return [{ json: ok ? out : { intent: "other", service_line: "unknown",
                             urgency: "this_week", summary: "Needs manual review" } }];
</code></pre>
<p>If validation fails, the lead still flows through with safe defaults and a "needs review" flag. <strong>A failed AI step must never mean a lost lead.</strong></p>
<h2>Step 5: Route deterministically</h2>
<p>An <strong>IF</strong> or <strong>Switch</strong> node routes on the validated fields:</p>
<ul>
<li><code>emergency</code>: notify the on-call person immediately by SMS.</li>
<li><code>spam</code>: log it, don't notify anyone, don't auto-reply.</li>
<li>Everything else: CRM upsert, a channel notification with the summary, and the standard acknowledgement.</li>
</ul>
<p>Keep the auto-reply honest and simple ("Thanks, we've got your message and will call you by…"). Don't let the LLM write customer-facing promises unless a human has approved the template.</p>
<h2>Step 6: Error handling and observability</h2>
<p>This is where production workflows differ from demos:</p>
<ul>
<li>Set an <strong>Error Workflow</strong> in n8n that alerts a human with the execution ID and the raw payload.</li>
<li>Use <strong>retry on fail</strong> for CRM and messaging nodes; APIs hiccup.</li>
<li>Write every lead to a simple log table (sheet or database) <em>first</em>, before anything else can fail. That's your safety net.</li>
<li>Review a sample of classifications weekly. Misclassifications tell you how to tighten the prompt or the enums.</li>
</ul>
<h2>n8n, Make or Zapier?</h2>
<p>The pattern is the same everywhere. Roughly: Zapier is fastest to start with and has a huge app catalogue; Make offers more visual control over branching and data mapping; n8n can be self-hosted and lets you drop into code freely, which helps with normalisation and validation steps like the ones above. Pick based on who will maintain it, not on feature lists.</p>
<h2>When to get help</h2>
<p>If you'd rather not own the maintenance, this is the kind of work agencies like Auroxa Tech do: <a href="https://auroxatech.com/workflow-automation">workflow automation in n8n, Make and Zapier</a> covering lead capture to CRM, instant follow-ups, booking and reminders, and AI steps that classify, summarise or draft replies inside a workflow. Whether you build or buy, insist on the same guardrails: closed outputs, validation, fallbacks, and a log that never loses a lead.</p>
<hr />
<p><em>Aiman Sheraz writes about practical AI automation for small businesses at <a href="https://auroxatech.com/">Auroxa Tech</a>, an AI automation and software company in Islamabad.</em></p>
]]></content:encoded></item><item><title><![CDATA[How In-Browser PDF Compression Works (and When Re-Rendering Pages Is the Right Trade-Off)]]></title><description><![CDATA[Why scanned PDFs get so big, how client-side compression by re-rendering pages shrinks them, and the trade-offs to understand first.]]></description><link>https://aimansheraztech.hashnode.dev/how-in-browser-pdf-compression-works-and-when-re-rendering-pages-is-the-right-trade-off</link><guid isPermaLink="true">https://aimansheraztech.hashnode.dev/how-in-browser-pdf-compression-works-and-when-re-rendering-pages-is-the-right-trade-off</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[pdf]]></category><category><![CDATA[webdev]]></category><category><![CDATA[privacy]]></category><category><![CDATA[performance]]></category><dc:creator><![CDATA[The Tools Kit]]></dc:creator><pubDate>Mon, 26 Oct 2026 10:56:23 GMT</pubDate><content:encoded><![CDATA[<p><em>Why scanned PDFs get so big, how client-side compression by re-rendering pages shrinks them, and the trade-offs to understand before you hit "compress".</em></p>
<p>"File too large, maximum 2 MB." Anyone who has uploaded a scanned ID, a signed contract or a stack of receipts to a government portal or job application has seen this message. The usual advice is "use an online PDF compressor", which means uploading a sensitive document to a server you know nothing about.</p>
<p>It doesn't have to work that way. Modern browsers can open, render and rebuild PDFs entirely on the client. This post explains what makes PDFs large, the main compression strategies, and how a re-rendering approach works in the browser, including the trade-offs.</p>
<h2>Why PDFs get big</h2>
<p>A PDF is a container. Its size usually comes from a few things:</p>
<ul>
<li><strong>Embedded images</strong>: by far the most common culprit. A phone scan of a page is a full-resolution photo, often several megabytes per page.</li>
<li><strong>Embedded fonts</strong>, especially full font files rather than subsets.</li>
<li><strong>Redundant objects</strong> left behind by repeated edits or "save as" from some tools.</li>
<li><strong>Metadata and thumbnails</strong>: usually small, occasionally not.</li>
</ul>
<p>If your PDF is a 40-page text report exported from a word processor, it's probably already small. If it's a 6-page scan, the images are the whole story.</p>
<h2>Two ways to compress</h2>
<p><strong>1. Structural optimisation.</strong> Parse the PDF, recompress or downsample each embedded image, subset fonts, and remove unused objects, keeping text as text. This preserves selectable text and is ideal for born-digital documents, but it's complex to implement robustly across the many PDF variants in the wild.</p>
<p><strong>2. Re-rendering.</strong> Draw each page to a canvas at a chosen resolution, encode that canvas as an optimised image (typically JPEG), and assemble a new PDF where each page is one image. It's conceptually simple and very effective for scans, because you're replacing an oversized photo with one sized for its purpose.</p>
<h2>Re-rendering in the browser, step by step</h2>
<p>Here's the shape of a client-side implementation using <a href="https://mozilla.github.io/pdf.js/">PDF.js</a> to render and a PDF-writing library (such as pdf-lib or jsPDF) to rebuild:</p>
<pre><code class="language-js">import * as pdfjsLib from "pdfjs-dist";
import { PDFDocument } from "pdf-lib";

async function compress(file, { scale = 1.2, quality = 0.6 } = {}) {
  const src = await pdfjsLib.getDocument({ data: await file.arrayBuffer() }).promise;
  const out = await PDFDocument.create();

  for (let i = 1; i &lt;= src.numPages; i++) {
    const page = await src.getPage(i);
    const viewport = page.getViewport({ scale });
    const canvas = document.createElement("canvas");
    canvas.width = viewport.width;
    canvas.height = viewport.height;
    await page.render({ canvasContext: canvas.getContext("2d"), viewport }).promise;

    const jpegBytes = await new Promise(r =&gt;
      canvas.toBlob(b =&gt; b.arrayBuffer().then(r), "image/jpeg", quality));
    const img = await out.embedJpg(jpegBytes);

    // Keep the original page dimensions (in PDF points)
    const { width, height } = page.getViewport({ scale: 1 });
    out.addPage([width, height]).drawImage(img, { x: 0, y: 0, width, height });
  }
  const bytes = await out.save();
  return bytes.length &lt; file.size ? new Blob([bytes], { type: "application/pdf" }) : file;
}
</code></pre>
<p>A few details that matter:</p>
<ul>
<li><strong><code>scale</code> and <code>quality</code> are your compression levels.</strong> A "light" preset might use a higher scale and quality; an "aggressive" preset lowers both. Expose three presets rather than raw sliders. Most people don't know what JPEG quality 0.6 means.</li>
<li><strong>Never return a bigger file.</strong> For text-only PDFs, re-rendering can <em>increase</em> size. The last line falls back to the original when that happens.</li>
<li><strong>Free memory as you go.</strong> Large scans at high scale can exhaust memory on phones. Process one page at a time, and release each canvas (set its width to 0) after encoding.</li>
<li><strong>Use a Web Worker</strong> for PDF.js parsing so the UI stays responsive on long documents.</li>
</ul>
<h2>The trade-offs, honestly</h2>
<p>Re-rendering is not free:</p>
<ul>
<li><strong>Text stops being selectable or searchable</strong>, because every page is now a picture. If you need to copy text later, keep the original. (You could run OCR afterwards, but that's a different tool.)</li>
<li><strong>Links, form fields and annotations are lost.</strong></li>
<li><strong>Vector graphics become raster</strong>, so very fine lines can soften at aggressive settings.</li>
</ul>
<p>For the typical "my scan is too big for this upload form" problem, none of these matter, and the size reduction is dramatic. For a contract you'll keep editing, use structural optimisation or keep the original file.</p>
<h2>Why client-side matters</h2>
<p>Doing this in the browser has real benefits beyond being a neat trick:</p>
<ul>
<li><strong>Privacy:</strong> the file never leaves the device. For IDs, medical letters and bank statements, that's the whole point.</li>
<li><strong>No upload wait:</strong> large files don't have to cross a slow connection twice.</li>
<li><strong>No server cost</strong>, which is why tools built this way can stay free.</li>
</ul>
<p>If you want to see this approach in a finished tool, The Tools Kit has a <a href="https://www.thetoolskit.co/compress-pdf">free in-browser PDF compressor</a> that works the same way: pages are re-rendered as optimised images at one of three levels (high quality, balanced, or small file), you see a before-and-after size comparison, the result is never larger than the original, and nothing is uploaded. Its page is also upfront about the trade-off: text stays readable but is no longer selectable.</p>
<h2>Takeaways</h2>
<ul>
<li>Most oversized PDFs are oversized because of images, usually scans.</li>
<li>Re-rendering pages to optimised JPEGs is simple and very effective for scans.</li>
<li>Always compare output size and fall back to the original.</li>
<li>Be explicit with users about lost text selection, links and forms.</li>
<li>Client-side processing is the privacy-respecting default for personal documents.</li>
</ul>
<hr />
<p><em>Aiman Sheraz writes about practical web tools and works on <a href="https://www.thetoolskit.co/">The Tools Kit</a>, a collection of free online tools that mostly run in your browser.</em></p>
]]></content:encoded></item><item><title><![CDATA[Latency Budgets for Live Speech Translation in the Browser]]></title><description><![CDATA[Where the milliseconds go in a speak-transcribe-translate-speak pipeline, and practical tricks for conversational browser voice translation.]]></description><link>https://aimansheraztech.hashnode.dev/latency-budgets-for-live-speech-translation-in-the-browser</link><guid isPermaLink="true">https://aimansheraztech.hashnode.dev/latency-budgets-for-live-speech-translation-in-the-browser</guid><category><![CDATA[webdev]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[speech]]></category><category><![CDATA[AI]]></category><category><![CDATA[webaudio]]></category><dc:creator><![CDATA[The Tools Kit]]></dc:creator><pubDate>Mon, 26 Oct 2026 10:54:27 GMT</pubDate><content:encoded><![CDATA[<p><em>Where the milliseconds go in a speak-transcribe-translate-speak pipeline, and the practical tricks that make browser-based voice translation feel conversational.</em></p>
<p>Text translation is a solved UX problem: paste, wait a second, read. Live <em>voice</em> translation is different. Two people are standing face to face, and every pause feels long. If the other person has to wait several seconds after you finish speaking before anything appears, they start talking over the system, and the conversation falls apart.</p>
<p>So the core engineering question isn't "which model is most accurate?" It's <strong>"how do we spend a latency budget?"</strong> This post breaks down the pipeline, where the time goes, and the techniques that help, with an emphasis on doing it all in a plain web page with no app install.</p>
<h2>The pipeline</h2>
<p>A two-way voice translator has four stages per utterance:</p>
<ol>
<li><strong>Capture</strong>: microphone audio into the page.</li>
<li><strong>Speech-to-text (STT)</strong>: audio into a transcript in the source language.</li>
<li><strong>Machine translation (MT)</strong>: transcript into the target language.</li>
<li><strong>Output</strong>: show the translated text, and optionally speak it with text-to-speech (TTS).</li>
</ol>
<p>Perceived latency is roughly the time from when the speaker <em>stops</em> talking to when the listener <em>starts</em> understanding. That's the number to optimise, not the total processing time.</p>
<h2>Stage 1: Capture without surprises</h2>
<p>In the browser, capture starts with <code>getUserMedia</code>:</p>
<pre><code class="language-js">const stream = await navigator.mediaDevices.getUserMedia({
  audio: { echoCancellation: true, noiseSuppression: true, channelCount: 1 }
});
</code></pre>
<p>Things that bite in practice:</p>
<ul>
<li><strong>Permission prompts</strong> cost time the first time, and only work in a secure context (HTTPS). See <a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia">MDN's getUserMedia reference</a> for the constraints and browser behaviour. Ask for the mic on an explicit user tap, with a sentence explaining why, so people don't reflexively hit "Block".</li>
<li><strong>Echo cancellation matters</strong> once you add TTS: without it, the phone hears its own translated speech and transcribes it.</li>
<li><strong>Send small chunks.</strong> Streaming audio in short frames (for example via an <code>AudioWorklet</code> or <code>MediaRecorder</code> with a small <code>timeslice</code>) lets downstream stages start before the sentence ends.</li>
</ul>
<h2>Stage 2: Streaming STT and endpointing</h2>
<p>The largest single latency win is <strong>streaming recognition with partial results</strong>. Instead of uploading a complete utterance, you stream audio and receive interim transcripts that firm up as the speaker continues.</p>
<p>The hard part is <em>endpointing</em>: deciding when the speaker has finished a thought. Too eager, and you translate half-sentences that change meaning when the verb arrives (a real problem for languages like German or Japanese, where key information comes late). Too cautious, and you add a dead second after every sentence.</p>
<p>Practical heuristics:</p>
<ul>
<li>Combine a silence threshold (often a few hundred milliseconds) with punctuation or "final" signals from the recogniser.</li>
<li>Allow a slightly longer silence threshold for languages with late verbs.</li>
<li>Give users a manual "done" tap as an escape hatch in noisy places.</li>
</ul>
<h2>Stage 3: Translate partials, but commit carefully</h2>
<p>Once you have streaming transcripts, you can translate <em>before</em> the speaker finishes. The trade-off is flicker: the translated line may rewrite itself as more words arrive.</p>
<p>A pattern that works well for UX:</p>
<ul>
<li>Translate interim transcripts and show them <strong>visually de-emphasised</strong> (lighter colour or italics).</li>
<li>When the recogniser marks a segment final, translate once more and <strong>commit</strong> it in normal styling.</li>
<li>Never speak (TTS) uncommitted text. Hearing a sentence retracted is far more confusing than seeing it change.</li>
</ul>
<p>Context also matters. Sending the previous one or two committed sentences along with the new segment helps with pronouns and topic continuity, at the cost of slightly larger requests.</p>
<h2>Stage 4: Output: read first, listen second</h2>
<p>TTS adds its own delay, since synthesis has to start and the audio has to play out at speaking speed. For face-to-face use, <strong>showing text immediately and speaking it in parallel</strong> usually feels faster than waiting for audio alone. The listener starts reading while the voice catches up.</p>
<p>Screen layout is part of latency too. If two people share one phone, splitting the screen so each person's half faces them removes the "pass the phone back and forth" step entirely.</p>
<h2>A sample budget</h2>
<p>Exact numbers depend on network, models and language pair, so treat this as a way to <em>think</em>, not a benchmark:</p>
<table>
<thead>
<tr>
<th>Stage</th>
<th>What you control</th>
</tr>
</thead>
<tbody><tr>
<td>Capture + upload</td>
<td>Chunk size, codec, keeping a connection open</td>
</tr>
<tr>
<td>Endpointing</td>
<td>Silence threshold, per-language tuning</td>
</tr>
<tr>
<td>STT finalisation</td>
<td>Streaming vs. batch recognition</td>
</tr>
<tr>
<td>MT</td>
<td>Translating partials, model choice, request size</td>
</tr>
<tr>
<td>Output</td>
<td>Text first, TTS in parallel</td>
</tr>
</tbody></table>
<p>Measure each stage separately with timestamps in your logs (<code>performance.now()</code> on the client, server timings in response headers). It's common to find that endpointing, not the model, is your biggest chunk.</p>
<h2>Why "no install" is a feature, not a compromise</h2>
<p>Native apps can shave some milliseconds, but for many real conversations the biggest delay is <strong>getting the other person set up</strong>. A hotel guest, a patient or a taxi passenger won't install an app for a two-minute conversation. A link or QR code that opens in any browser removes that step entirely, and works on whatever phone and earpiece people already have.</p>
<p>That's the design choice behind Mingle, a browser-based voice translator built at Auroxa Tech: you pick two languages, allow the mic, and talk, and the other person doesn't install anything. If you want to see the UX patterns above (split-screen face-to-face mode, text plus spoken output, any Bluetooth earpiece or none), the page on <a href="https://mingle.fit/how-it-works">how browser-based live translation works in practice</a> walks through the flow step by step.</p>
<h2>Takeaways</h2>
<ul>
<li>Optimise <strong>perceived</strong> latency: speaker stops to listener understands.</li>
<li>Stream everything: audio, recognition, and translation of partials.</li>
<li>Tune endpointing per language; it's often the biggest hidden cost.</li>
<li>Show text immediately and treat TTS as a parallel channel.</li>
<li>Remove setup friction for the <em>other</em> person; it beats any model speedup.</li>
</ul>
<hr />
<p><em>Aiman Sheraz writes about practical AI and web products and works on <a href="https://mingle.fit/">Mingle</a>, a real-time voice translator that runs in the browser.</em></p>
]]></content:encoded></item><item><title><![CDATA[Stop Re-Explaining Your Brand to the AI: Designing a Persistent Brand Memory for Social Posts]]></title><description><![CDATA[A practical pattern for giving AI-generated social content a stable voice: a structured brand spec, guardrails, and a feedback loop.]]></description><link>https://aimansheraztech.hashnode.dev/stop-re-explaining-your-brand-to-the-ai-designing-a-persistent-brand-memory-for-social-posts</link><guid isPermaLink="true">https://aimansheraztech.hashnode.dev/stop-re-explaining-your-brand-to-the-ai-designing-a-persistent-brand-memory-for-social-posts</guid><category><![CDATA[AI]]></category><category><![CDATA[llm]]></category><category><![CDATA[#socialmedia]]></category><category><![CDATA[#PromptEngineering]]></category><category><![CDATA[Productivity]]></category><dc:creator><![CDATA[The Tools Kit]]></dc:creator><pubDate>Mon, 26 Oct 2026 10:53:12 GMT</pubDate><content:encoded><![CDATA[<p><em>A practical pattern for giving AI-generated social content a stable voice: a structured brand spec, guardrails, and a feedback loop that learns from edits.</em></p>
<p>If you've ever used a chat model to write social posts for a small business, you know the pattern. Monday's captions are great because you spent twenty minutes explaining the brand. By Thursday you're in a new chat, the context is gone, and the output reads like every other AI caption on the internet: "Elevate your morning routine! âœ¨"</p>
<p>The model isn't the problem. <strong>Memory is.</strong> A chat session is stateless across days. The fix is to treat brand voice the way we treat config in software: write it down once, version it, inject it every time, and update it when reality disagrees.</p>
<p>This post walks through a pattern you can build yourself in an afternoon.</p>
<h2>1. Model the brand as structured data, not a paragraph</h2>
<p>Most people paste a "brand description" paragraph into the prompt. Paragraphs are hard to diff, hard to update, and easy for a model to skim. Split it into fields:</p>
<pre><code class="language-json">{
  "brand": "Northside Bakery",
  "audience": "Local families and office workers within 3 km",
  "voice": {
    "tone": ["warm", "plain-spoken", "a little cheeky"],
    "vocabulary_use": ["fresh this morning", "neighbours"],
    "vocabulary_avoid": ["elevate", "indulge", "game-changer"],
    "emoji": "max 1 per post, food emoji only"
  },
  "visual": { "colors": ["#F4E1C1", "#7A3E1D"], "logo_rules": "bottom-right, never recolored" },
  "offers": ["Weekday 8am coffee + croissant combo"],
  "ctas": ["Pre-order on our site", "Drop by before 10"],
  "banned_topics": ["politics", "competitor names", "health claims"]
}
</code></pre>
<p>Why this shape works:</p>
<ul>
<li><strong><code>vocabulary_avoid</code></strong> does more for "not sounding like AI" than any adjective in <code>tone</code>. Models drift toward a recognisable set of marketing words; banning them explicitly is cheap and effective.</li>
<li><strong><code>banned_topics</code></strong> is a guardrail, not a style note. Keep it separate so you can enforce it in a check after generation, not just in the prompt.</li>
<li><strong><code>offers</code> and <code>ctas</code></strong> stop the model from inventing promotions. Anything not in this list shouldn't appear in a post.</li>
</ul>
<h2>2. Inject it on every call, in a fixed position</h2>
<p>Put the spec in the system prompt, rendered consistently, and keep the per-post request in the user message:</p>
<pre><code class="language-python">def build_messages(spec: dict, request: str) -&gt; list[dict]:
    system = (
        "You write social posts for one brand. Follow the brand spec exactly.\n"
        "Never mention offers or CTAs that are not listed. Never touch banned topics.\n\n"
        f"BRAND SPEC (JSON):\n{json.dumps(spec, indent=2)}"
    )
    return [
        {"role": "system", "content": system},
        {"role": "user", "content": request},
    ]
</code></pre>
<p>Keeping the spec in the same slot every time makes outputs more consistent and makes debugging easier: if a post goes off-voice, you know the spec was there, so either the spec is ambiguous or the request contradicted it.</p>
<h2>3. Validate after generation</h2>
<p>Prompts are suggestions. A few deterministic checks catch most of the embarrassing failures before a human ever sees them:</p>
<pre><code class="language-python">def violations(post: str, spec: dict) -&gt; list[str]:
    text = post.lower()
    issues = [f"avoid-word: {w}" for w in spec["voice"]["vocabulary_avoid"] if w in text]
    issues += [f"banned-topic: {t}" for t in spec["banned_topics"] if t in text]
    emoji_count = sum(1 for ch in post if ord(ch) &gt; 0x1F000)
    if emoji_count &gt; 1:
        issues.append(f"too many emoji ({emoji_count})")
    return issues
</code></pre>
<p>Keyword matching is crude for topics, so for <code>banned_topics</code> you can add a second, cheap model call that answers a yes/no classification ("Does this post discuss politics?"). Regenerate on failure, with the violation list appended to the request.</p>
<h2>4. Close the loop: learn from edits</h2>
<p>This is the part most homegrown setups skip, and it's where the value compounds. Every time a human rejects or rewrites a post, that's labelled data about the brand.</p>
<p>Store each correction:</p>
<pre><code class="language-json">{ "original": "Indulge in our new sourdough!", "edited": "New sourdough's out. It goes fast.", "reason": "too salesy" }
</code></pre>
<p>Then, periodically (weekly works), ask a model to propose spec changes from the accumulated corrections:</p>
<blockquote>
<p>Here is the current brand spec and 14 human corrections with reasons. Propose additions to <code>vocabulary_avoid</code>, <code>tone</code>, or <code>banned_topics</code> that would have prevented these corrections. Output a JSON patch. Do not remove existing rules.</p>
</blockquote>
<p>Review the patch like a pull request, merge it, and bump a version number. Now your fifth week of content benefits from everything learned in the first four, and you can roll back if a change makes things worse.</p>
<h2>5. Keep each brand isolated</h2>
<p>If you manage more than one brand (an agency, or an owner with two businesses), never share a conversation or memory store between them. One spec file per brand, one corrections log per brand, and a hard check that the generation job loads exactly one spec. Cross-contamination ("why is the dentist's post using the bakery's CTA?") is a real and embarrassing failure mode.</p>
<h2>Build vs. buy</h2>
<p>The pattern above is a few hundred lines of code plus a scheduler and a posting integration. Building it is a great learning project, and for one brand it may be all you need. The cost shows up later: platform API changes, image generation that also respects colors and logo rules, approval flows, and per-client isolation once you're handling several accounts.</p>
<p>Products in this space are converging on the same design. For example, <a href="https://www.zolyp.com/features/brand-brain">Zolyp's Brand Brain</a> captures tone, colors, logo, audience, banned topics and selling points from a business's site and edits, uses that same source across its creation tools, and updates when someone rejects a post with a reason. That's the same "spec plus feedback loop" idea, packaged so a small business owner doesn't have to maintain JSON.</p>
<p>Whichever route you take, the lesson is the same: <strong>brand voice is state, so treat it like state.</strong> Write it down in a structured form, inject it every time, check outputs against it, and let human corrections update it.</p>
<h2>Quick checklist</h2>
<ul>
<li><input disabled type="checkbox" /> Brand spec stored as structured data under version control</li>
<li><input disabled type="checkbox" /> Explicit <code>vocabulary_avoid</code> list (start with the words you're sick of seeing)</li>
<li><input disabled type="checkbox" /> Offers and CTAs whitelisted, so nothing gets invented</li>
<li><input disabled type="checkbox" /> Post-generation checks with automatic regenerate-on-fail</li>
<li><input disabled type="checkbox" /> Corrections logged with a reason, reviewed weekly into spec patches</li>
<li><input disabled type="checkbox" /> One spec per brand, never shared</li>
</ul>
<hr />
<p><em>Aiman Sheraz writes about practical AI for small teams and works on <a href="https://www.zolyp.com/">Zolyp</a>, an AI social media manager for small businesses and agencies.</em></p>
]]></content:encoded></item></channel></rss>