<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jonas Nguyen</title>
    <description>The latest articles on DEV Community by Jonas Nguyen (@jonas_nguyen).</description>
    <link>https://dev.to/jonas_nguyen</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4122484%2F3f82be42-a2fd-4a74-84d3-5de5442b2771.png</url>
      <title>DEV Community: Jonas Nguyen</title>
      <link>https://dev.to/jonas_nguyen</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jonas_nguyen"/>
    <language>en</language>
    <item>
      <title>An AI that drafts your posts, and code that checks its facts</title>
      <dc:creator>Jonas Nguyen</dc:creator>
      <pubDate>Thu, 12 Nov 2026 15:10:29 +0000</pubDate>
      <link>https://dev.to/jonas_nguyen/an-ai-that-drafts-your-posts-and-code-that-checks-its-facts-g3d</link>
      <guid>https://dev.to/jonas_nguyen/an-ai-that-drafts-your-posts-and-code-that-checks-its-facts-g3d</guid>
      <description>&lt;p&gt;The most common complaint about social media for a small business is not "I don't know how to post". It is "I never have anything to post". Autopilot is Clienzo's answer: it drafts the coming week of posts, and the owner approves them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every post starts from a source
&lt;/h2&gt;

&lt;p&gt;An AI asked to "write five posts for a nail salon" produces five posts that could belong to any nail salon. So every Autopilot draft has to come from something concrete, and the source is shown next to the draft:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;trends&lt;/strong&gt; from trade press, news and research sites, each with its link — never a competitor's blog;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;competitors' best recent posts&lt;/strong&gt;, used to learn the &lt;em&gt;format&lt;/em&gt; that works; their words are never copied;&lt;/li&gt;
&lt;li&gt;the business's own &lt;strong&gt;profile&lt;/strong&gt;: offers and prices, hours, promotions with end dates, policies, voice, and a list of things it never says.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The fact check is code
&lt;/h2&gt;

&lt;p&gt;The risk with AI-written posts for a business is not a clumsy sentence. It is a wrong price, an expired promotion, the wrong opening hours. Asking the model to "double-check" does not make that go away.&lt;/p&gt;

&lt;p&gt;So after a draft is written, plain code extracts every price, percentage, time, date and "N-day" term from it and checks that each one appears in the business profile. Phrases from the never-say list are blocked too. A draft that fails cannot be approved until it is fixed.&lt;/p&gt;

&lt;p&gt;Testing it against real output, the check caught a price that did not exist and an invented "free trial". It also produced false positives — the word "businesses" matched a plan called "Business" — which needed fixing so the check does not cry wolf.&lt;/p&gt;

&lt;h2&gt;
  
  
  One approval, every network
&lt;/h2&gt;

&lt;p&gt;Approving a draft schedules it to every connected network where it fits, with an image. Nothing goes out without that approval.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is not, yet
&lt;/h2&gt;

&lt;p&gt;There is no weekly autopilot running on its own; you start each run. Automatic scheduling would only make sense after weeks of drafts that needed almost no edits — and that trust has to be earned, not assumed.&lt;/p&gt;

&lt;p&gt;Autopilot is on the paid plans. Next part: the quieter problems — tokens that expire, and an email outage I did not notice for four days.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jonasnguyen.com/writing/autopilot-checks-its-own-facts/" rel="noopener noreferrer"&gt;jonasnguyen.com&lt;/a&gt;, where I write about building Clienzo and the next startups.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>ai</category>
      <category>product</category>
    </item>
    <item>
      <title>Shipping an MCP server for a small SaaS</title>
      <dc:creator>Jonas Nguyen</dc:creator>
      <pubDate>Tue, 10 Nov 2026 15:10:21 +0000</pubDate>
      <link>https://dev.to/jonas_nguyen/shipping-an-mcp-server-for-a-small-saas-411p</link>
      <guid>https://dev.to/jonas_nguyen/shipping-an-mcp-server-for-a-small-saas-411p</guid>
      <description>&lt;p&gt;The Model Context Protocol lets an AI assistant call tools in another service. For Clienzo, it means an assistant can look at your connected accounts, create a post, schedule it, check how it did and retry it where it failed — inside the same conversation where you wrote it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother
&lt;/h2&gt;

&lt;p&gt;People already draft posts with an assistant. The last step — getting that text onto five networks — is exactly the boring part Clienzo exists to remove. An MCP server puts Clienzo where the writing happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Few, boring tools
&lt;/h2&gt;

&lt;p&gt;The server has eight tools: list the available networks, list your connected accounts, create a post, list posts, get one post, delete a post, read analytics, and retry a post on the accounts where it failed.&lt;/p&gt;

&lt;p&gt;They map closely onto the public API from part 11, with the same permissions behind them. Small tools with plain names are easier for a model to pick correctly than a few clever ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stateless on purpose
&lt;/h2&gt;

&lt;p&gt;Each request is one complete exchange. No sessions, no long-lived connection. Clienzo runs on a platform where the server instance that answered the first call may be gone by the second, so anything kept in memory between calls would be a bug waiting to happen.&lt;/p&gt;

&lt;p&gt;When a tool fails — a caption too long for a network, an account that needs reconnecting — the failure comes back as a tool result the model can read and act on, not as a protocol error.&lt;/p&gt;

&lt;h2&gt;
  
  
  API key first, OAuth second
&lt;/h2&gt;

&lt;p&gt;The first version took an API key in a header. That works for Claude Code, Cursor and anything else where you can set a header — but not for the one-click connectors in hosted assistants. Those need OAuth.&lt;/p&gt;

&lt;p&gt;Adding OAuth taught me a few things the spec does not make obvious:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Clients may register in two ways: by pointing at a published metadata document, or by registering dynamically. The first is now preferred; a client only picks it if the server advertises it correctly, and otherwise silently falls back to the second.&lt;/li&gt;
&lt;li&gt;Desktop tools redirect back to &lt;code&gt;localhost&lt;/code&gt; on a random port, so redirect matching has to ignore the port.&lt;/li&gt;
&lt;li&gt;There is no machine-to-machine flow: every connection needs a person to approve it. That is why API keys stay. Nothing approves a consent screen at 3 a.m. for a script.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;MCP is available on every plan and does not use up an API key slot.&lt;/p&gt;

&lt;p&gt;Next: Autopilot, the first feature that writes posts for you — and checks its own facts.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jonasnguyen.com/writing/shipping-an-mcp-server/" rel="noopener noreferrer"&gt;jonasnguyen.com&lt;/a&gt;, where I write about building Clienzo and the next startups.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>mcp</category>
      <category>ai</category>
      <category>api</category>
    </item>
    <item>
      <title>Opening a public API for a social scheduler</title>
      <dc:creator>Jonas Nguyen</dc:creator>
      <pubDate>Sun, 08 Nov 2026 15:10:19 +0000</pubDate>
      <link>https://dev.to/jonas_nguyen/opening-a-public-api-for-a-social-scheduler-4892</link>
      <guid>https://dev.to/jonas_nguyen/opening-a-public-api-for-a-social-scheduler-4892</guid>
      <description>&lt;p&gt;The public API was the core of the bet from part 1: anything that can make an HTTP request should be able to schedule a post. Before opening it, I wrote down the ways that could go wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can go wrong
&lt;/h2&gt;

&lt;p&gt;When people post through the app, a human looks at every post. Through an API, a script does, and scripts fail in specific ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a request times out, the script retries, and the post goes out &lt;strong&gt;twice&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;a bug in a loop schedules the same post &lt;strong&gt;hundreds of times&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;a test run against production publishes a test post &lt;strong&gt;to real followers&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The guardrails
&lt;/h2&gt;

&lt;p&gt;Each of those got its own answer before launch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API keys with scopes.&lt;/strong&gt; A key can be limited to what it needs — reading posts, writing posts, reading or replying to comments. Agencies can give each client business its own key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A rate limit per key&lt;/strong&gt;, so one runaway script cannot starve everyone else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency keys.&lt;/strong&gt; Send the same key with a retried request, and Clienzo returns the original result instead of creating a second post.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A daily post cap per account&lt;/strong&gt;, so a loop has a ceiling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dry_run&lt;/code&gt;.&lt;/strong&gt; Check a post against every selected network's rules — caption length, image count, formats — and get the same validation errors, without publishing anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Stronger before wider
&lt;/h2&gt;

&lt;p&gt;Opening an API means more traffic you do not control. So before launch I also moved publishing to its own service, raised the number of posts that can publish at the same moment, and made media streaming the default (all of part 3).&lt;/p&gt;

&lt;h2&gt;
  
  
  What it covers today
&lt;/h2&gt;

&lt;p&gt;Posts (create, schedule, list, read, delete, retry the failed accounts), media uploads, and the comment inbox — listing, reading, replying and marking comments done. Per-post numbers come back with each post. The documentation lives on clienzo.co.&lt;/p&gt;

&lt;p&gt;Old keys do not silently gain new powers: when comment access was added, existing keys did not get the new scopes. You create a new key to use them.&lt;/p&gt;

&lt;p&gt;Next: the MCP server, which is the same API, one step closer to where people write.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jonasnguyen.com/writing/a-public-api-for-a-scheduler/" rel="noopener noreferrer"&gt;jonasnguyen.com&lt;/a&gt;, where I write about building Clienzo and the next startups.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>api</category>
      <category>architecture</category>
    </item>
    <item>
      <title>One inbox for the comments that come back</title>
      <dc:creator>Jonas Nguyen</dc:creator>
      <pubDate>Fri, 06 Nov 2026 15:10:18 +0000</pubDate>
      <link>https://dev.to/jonas_nguyen/one-inbox-for-the-comments-that-come-back-2191</link>
      <guid>https://dev.to/jonas_nguyen/one-inbox-for-the-comments-that-come-back-2191</guid>
      <description>&lt;p&gt;A post is the start of a conversation, and the conversation happens in seven different apps. Clienzo's inbox brings the comments back to one place: Facebook, Instagram, Threads, Bluesky, Mastodon, DEV and Tumblr.&lt;/p&gt;

&lt;p&gt;From there you can reply, hide a comment, or mark it done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pulled, not pushed
&lt;/h2&gt;

&lt;p&gt;The textbook way to get comments is webhooks: the network calls you when something new arrives. Clienzo pulls instead. It looks at the posts it published in the last 30 days and asks each network for new comments, a limited number of posts at a time, and no more than once a minute per account owner.&lt;/p&gt;

&lt;p&gt;Pulling is less instant. But webhooks need each network's app to be live with an extra endpoint and, on some platforms, extra review. Pulling worked with what Clienzo already had, on every network, from day one. Webhooks can come later for direct messages, where speed matters more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permissions, again
&lt;/h2&gt;

&lt;p&gt;Reading and replying to comments needs its own permissions on Meta's side — the second review round from part 6. And every reply, hide or status change checks twice that both the comment and the connected account belong to the person asking.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI suggestions that missed the point
&lt;/h2&gt;

&lt;p&gt;The inbox can suggest replies. The first version was not good. On a real thread, the suggestions were off-topic, and looking into it I found the model was set up to fail:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it saw only the first 280 characters of the original post,&lt;/li&gt;
&lt;li&gt;earlier messages from the person replying were labelled "Someone", so the model lost track of who said what,&lt;/li&gt;
&lt;li&gt;it kept offering to "send a DM" even when the business had already offered that,&lt;/li&gt;
&lt;li&gt;and it knew nothing about the business it was speaking for.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix was not a different model. It was better context: the full post, clear roles ("Them", "You"), @mentions stripped, guidance per network, and a short profile the owner writes once — what the business does, its voice, facts it can rely on, and things it should never say. If that profile is empty, Clienzo falls back to the bios of the connected accounts.&lt;/p&gt;

&lt;p&gt;Suggestions are suggestions. Nothing is sent until you press Reply.&lt;/p&gt;

&lt;p&gt;Next: the public API, and the guardrails I added before opening it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jonasnguyen.com/writing/one-inbox-for-comments/" rel="noopener noreferrer"&gt;jonasnguyen.com&lt;/a&gt;, where I write about building Clienzo and the next startups.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>product</category>
      <category>ai</category>
    </item>
    <item>
      <title>Chat apps are channels too</title>
      <dc:creator>Jonas Nguyen</dc:creator>
      <pubDate>Wed, 04 Nov 2026 15:10:18 +0000</pubDate>
      <link>https://dev.to/jonas_nguyen/chat-apps-are-channels-too-56pe</link>
      <guid>https://dev.to/jonas_nguyen/chat-apps-are-channels-too-56pe</guid>
      <description>&lt;p&gt;When I looked at where small products actually talk to their users, a lot of it was not on the classic social networks. It was in a Discord server, a Telegram channel, a Slack community.&lt;/p&gt;

&lt;p&gt;So Clienzo treats those as channels, with the same composer, schedule and per-channel result as Facebook or LinkedIn.&lt;/p&gt;

&lt;h2&gt;
  
  
  How each one connects
&lt;/h2&gt;

&lt;p&gt;Each chat app has a different idea of what "connecting an app" means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Discord&lt;/strong&gt;: you paste a webhook URL from the channel's Integrations settings. No login, no review. Up to 2,000 characters and 10 files per message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telegram&lt;/strong&gt;: you add the Clienzo bot to your channel or group, then post the short code Clienzo shows you. That proves you control the channel before anything gets linked to your account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slack&lt;/strong&gt;: a normal sign-in, then you pick the channel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Viber&lt;/strong&gt;: you paste the token from the channel's developer tools.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of them needed a platform review, which is a large part of why they shipped quickly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alerts in the same place
&lt;/h2&gt;

&lt;p&gt;In Discord, Telegram or Slack, Clienzo can also tell you things: a post published, partly published or failed, or an account that needs reconnecting. You pick which events. For a small team that already lives in Discord or Slack, that beats another email.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not Microsoft Teams
&lt;/h2&gt;

&lt;p&gt;Teams was on the first list and came off it. It is built around enterprise tenants and admin approval, which is the opposite of the people Clienzo is for: founders and small teams who decide for themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I cannot measure here
&lt;/h2&gt;

&lt;p&gt;The trade-off: chat apps do not report views or reactions to an app for each message. Those channels show a blank in analytics, as described in part 5. You find out how a post did by being in the conversation — which, for these channels, is the point.&lt;/p&gt;

&lt;p&gt;Next: one inbox for the comments coming back.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jonasnguyen.com/writing/chat-apps-are-channels-too/" rel="noopener noreferrer"&gt;jonasnguyen.com&lt;/a&gt;, where I write about building Clienzo and the next startups.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>integrations</category>
      <category>productstrategy</category>
    </item>
    <item>
      <title>Small networks, big quirks</title>
      <dc:creator>Jonas Nguyen</dc:creator>
      <pubDate>Mon, 02 Nov 2026 15:10:06 +0000</pubDate>
      <link>https://dev.to/jonas_nguyen/small-networks-big-quirks-gib</link>
      <guid>https://dev.to/jonas_nguyen/small-networks-big-quirks-gib</guid>
      <description>&lt;p&gt;The big platforms have big review processes. The smaller ones have smaller surprises — the kind you only find with a real account and a real post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pinterest: pins nobody else could see
&lt;/h2&gt;

&lt;p&gt;Pinterest gives a new app &lt;strong&gt;trial access&lt;/strong&gt; first. With it, everything works: you connect, you create a pin, the API says success. But the pin is a sandbox object only its author can see.&lt;/p&gt;

&lt;p&gt;Clienzo marked Pinterest "in review" until &lt;strong&gt;Standard access&lt;/strong&gt; was approved in October. On the same day I found that Pinterest tokens last 30 days and nothing was refreshing them, so older connections had quietly died. That got fixed alongside, together with per-pin analytics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dribbble: uploaded, and invisible
&lt;/h2&gt;

&lt;p&gt;Dribbble's API took the upload without complaint, and the shot did not appear anywhere. The reason was on the account: Dribbble hides the shots of a regular account until it is approved as a Designer account.&lt;/p&gt;

&lt;p&gt;Clienzo now explains this before you connect, warns on the post's result, and labels such connections "Not public".&lt;/p&gt;

&lt;p&gt;Two more Dribbble details. The docs say the API accepts 400×300 or 800×600 images; a test showed it takes 1600×1200 too, which is what most shots use, so Clienzo sends that first. And the title — taken from the first line of the caption — cannot be longer than 64 characters, which I found through a real rejected post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tumblr: one limit for everyone
&lt;/h2&gt;

&lt;p&gt;A new Tumblr app gets 1,000 API calls an hour and 5,000 a day — &lt;strong&gt;shared across every customer&lt;/strong&gt; of the app, not per user. Most of those calls go to reading comments for the inbox, not to posting.&lt;/p&gt;

&lt;p&gt;Clienzo treats a rate-limit error from Tumblr as its own limit, not the customer's, and alerts me when the remaining quota runs low. Getting the limit raised is a conversation with Tumblr that is still going.&lt;/p&gt;

&lt;h2&gt;
  
  
  Viber: rules about files
&lt;/h2&gt;

&lt;p&gt;Viber channels accept pictures only as JPEGs under 1 MB, one per message. So Clienzo re-encodes each picture and stores a copy that meets those rules before sending it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;None of these were in a "getting started" guide. Each one showed up the first time a real post went to a real account. That is now a rule for me: a network is not "live" on the website until a real post has gone through it.&lt;/p&gt;

&lt;p&gt;Next: chat apps, and why Discord and Telegram are channels in Clienzo.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jonasnguyen.com/writing/small-networks-big-quirks/" rel="noopener noreferrer"&gt;jonasnguyen.com&lt;/a&gt;, where I write about building Clienzo and the next startups.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>integrations</category>
      <category>platformreview</category>
    </item>
    <item>
      <title>YouTube: passing OAuth verification is not the end</title>
      <dc:creator>Jonas Nguyen</dc:creator>
      <pubDate>Sat, 31 Oct 2026 15:10:23 +0000</pubDate>
      <link>https://dev.to/jonas_nguyen/youtube-passing-oauth-verification-is-not-the-end-d8i</link>
      <guid>https://dev.to/jonas_nguyen/youtube-passing-oauth-verification-is-not-the-end-d8i</guid>
      <description>&lt;p&gt;YouTube was the network where I learned the difference between "approved" and "done".&lt;/p&gt;

&lt;h2&gt;
  
  
  Check one: OAuth verification
&lt;/h2&gt;

&lt;p&gt;To let other people connect their YouTube channel, Clienzo asks for two scopes: one to upload videos and one to read basic channel and video data. Both are classed as sensitive, which means Google verifies the app before strangers can use it.&lt;/p&gt;

&lt;p&gt;That review wanted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a demo video showing exactly how each scope is used,&lt;/li&gt;
&lt;li&gt;a test account the reviewers could sign in with, working without a paid plan,&lt;/li&gt;
&lt;li&gt;a privacy policy that matches what the app actually does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last one is easy to get wrong. My first policy did not mention that Clienzo reads video statistics, even though the analytics do exactly that. A mismatch between the policy and the API calls is a common reason for rejection, so I rewrote that section before submitting.&lt;/p&gt;

&lt;p&gt;There was a round of follow-up questions, and the verification completed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check two: the API audit
&lt;/h2&gt;

&lt;p&gt;Here is the part most people miss. Passing OAuth verification does &lt;strong&gt;not&lt;/strong&gt; let you publish public videos.&lt;/p&gt;

&lt;p&gt;Any API project created after July 2020 has every upload locked to &lt;strong&gt;private&lt;/strong&gt;, whatever privacy setting the app sends, until it passes a separate YouTube API compliance audit. The form for it is titled "Audit and Quota Extension", which makes it sound optional. It is not.&lt;/p&gt;

&lt;p&gt;Because of this, Clienzo's YouTube publisher reads back the privacy status YouTube actually applied after each upload. If the video was forced to private, the post's result says so — instead of reporting "published" for a video nobody can see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;The audit was approved in September and the OAuth review completed in October. Nothing is pending on the Google side now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson
&lt;/h2&gt;

&lt;p&gt;Read the platform's docs for the word "audit" before you plan a launch date. With most platforms, being allowed to connect and being allowed to publish publicly are separate approvals.&lt;/p&gt;

&lt;p&gt;Next: the smaller networks, where the quirks were stranger.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jonasnguyen.com/writing/youtube-verification-is-not-the-audit/" rel="noopener noreferrer"&gt;jonasnguyen.com&lt;/a&gt;, where I write about building Clienzo and the next startups.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>platformreview</category>
      <category>youtube</category>
      <category>google</category>
    </item>
    <item>
      <title>The Meta App Review trap: every permission approved, login still broken</title>
      <dc:creator>Jonas Nguyen</dc:creator>
      <pubDate>Thu, 29 Oct 2026 15:10:22 +0000</pubDate>
      <link>https://dev.to/jonas_nguyen/the-meta-app-review-trap-every-permission-approved-login-still-broken-4m43</link>
      <guid>https://dev.to/jonas_nguyen/the-meta-app-review-trap-every-permission-approved-login-still-broken-4m43</guid>
      <description>&lt;p&gt;To post to Facebook Pages, Instagram and Threads on someone else's behalf, an app needs Meta to approve each permission through App Review: you describe the use, record a screencast of it, and wait.&lt;/p&gt;

&lt;p&gt;Clienzo went through that and passed. Then it did not work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;Accounts with a role on the app — me — could connect Facebook fine. Everyone else saw Meta's login dialog say that Facebook Login was "currently unavailable for this app, since we are updating additional details for this app."&lt;/p&gt;

&lt;p&gt;That message points you towards business verification, data protection forms, app settings. I checked all of them. None of it was the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cause
&lt;/h2&gt;

&lt;p&gt;The login dialog itself needs &lt;strong&gt;Advanced Access to &lt;code&gt;public_profile&lt;/code&gt;&lt;/strong&gt;. Without it, Meta does not degrade gracefully — it blocks the whole dialog for anyone who is not on the app's team.&lt;/p&gt;

&lt;p&gt;The part that cost the most time: asking Meta's API for the app's permissions reported &lt;code&gt;public_profile&lt;/code&gt; as &lt;code&gt;live&lt;/code&gt; the entire time. That field does not tell Standard Access and Advanced Access apart. The only reliable place to check is the Advanced Access column in the App Review section of the dashboard.&lt;/p&gt;

&lt;p&gt;After requesting Advanced Access, Facebook, Instagram and Threads worked for every customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two apps, two reviews
&lt;/h2&gt;

&lt;p&gt;Threads is its own app on Meta's side, so it has its own review. And when Clienzo later added a comment inbox and post insights, those permissions needed a second round of review — again split across the two apps.&lt;/p&gt;

&lt;p&gt;One more trap from that round: for a Business Login app, some permissions are declared in the Meta dashboard's login configuration, not in code. Changing the code does nothing for those. Others — Threads, and Instagram's own login — are the opposite. I wrote down which is which, because I will not remember next time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell myself
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If login fails only for people without a role, check Advanced Access for &lt;code&gt;public_profile&lt;/code&gt; first.&lt;/li&gt;
&lt;li&gt;Do not trust &lt;code&gt;status: live&lt;/code&gt; as proof of Advanced Access.&lt;/li&gt;
&lt;li&gt;Record the screencast as you build the feature, not the night before you submit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both review rounds are approved now. Next: Google, where passing verification turned out not to be the end either.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jonasnguyen.com/writing/the-meta-app-review-trap/" rel="noopener noreferrer"&gt;jonasnguyen.com&lt;/a&gt;, where I write about building Clienzo and the next startups.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>platformreview</category>
      <category>meta</category>
      <category>lessons</category>
    </item>
    <item>
      <title>Analytics that leave a blank instead of guessing</title>
      <dc:creator>Jonas Nguyen</dc:creator>
      <pubDate>Tue, 27 Oct 2026 15:10:09 +0000</pubDate>
      <link>https://dev.to/jonas_nguyen/analytics-that-leave-a-blank-instead-of-guessing-2hil</link>
      <guid>https://dev.to/jonas_nguyen/analytics-that-leave-a-blank-instead-of-guessing-2hil</guid>
      <description>&lt;p&gt;The fastest way to lose someone's trust in an analytics page is to show them a number that is not real. So Clienzo's analytics follow one rule: if a network does not report it, the cell is blank, and Clienzo says why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not every network reports
&lt;/h2&gt;

&lt;p&gt;Of the sixteen networks Clienzo posts to, a good number simply do not give an app per-post numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Discord&lt;/strong&gt; webhooks, &lt;strong&gt;Slack&lt;/strong&gt;, &lt;strong&gt;Telegram&lt;/strong&gt; channels and &lt;strong&gt;Viber&lt;/strong&gt; channels do not report views or reactions per message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dribbble&lt;/strong&gt;'s API does not report views or likes on a shot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dailymotion&lt;/strong&gt; retired per-video view counts from its API this year.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn&lt;/strong&gt; reports them only through a separate API that needs its own approval.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The networks that do report — Facebook, Instagram, Threads, YouTube, Pinterest, Bluesky, Mastodon, DEV and Tumblr — each report a different mix of views, likes, comments and shares. The table on Clienzo's site lists exactly which.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I decided not to show
&lt;/h2&gt;

&lt;p&gt;Two popular numbers are missing on purpose: &lt;strong&gt;engagement rate&lt;/strong&gt; and &lt;strong&gt;reach&lt;/strong&gt;. Both need a denominator — followers, impressions — that most networks do not hand to an app for each post. Computing them anyway would mean inventing the denominator.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that ate two thirds of the data
&lt;/h2&gt;

&lt;p&gt;Clienzo measures each post three times: an hour after publishing, after a day, and after a week. For a while the job overwrote the result each time. The one-hour and one-day numbers were measured, saved, and then replaced by the next pass — gone for good, because no network will tell you what a post's numbers &lt;em&gt;were&lt;/em&gt; yesterday.&lt;/p&gt;

&lt;p&gt;Now every pass is kept separately. That made a new number possible: how much of a post's engagement arrived in its &lt;strong&gt;first hour&lt;/strong&gt;. It only shows once a post has been measured past that first hour; before that, the first hour &lt;em&gt;is&lt;/em&gt; the total and the share would always read 100%.&lt;/p&gt;

&lt;h2&gt;
  
  
  What came after
&lt;/h2&gt;

&lt;p&gt;With history kept, the page could grow up: comparison with the previous period, results per account instead of only per network, a day-and-hour view of when posts do best, and cuts by content type — image, video, text, caption length, with or without a link. The heatmap needs a fair number of measured posts before it means anything; with three posts it is noise.&lt;/p&gt;

&lt;p&gt;Next: the review process that decided when Facebook, Instagram and Threads could launch.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jonasnguyen.com/writing/analytics-with-honest-blanks/" rel="noopener noreferrer"&gt;jonasnguyen.com&lt;/a&gt;, where I write about building Clienzo and the next startups.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>analytics</category>
      <category>product</category>
    </item>
    <item>
      <title>When a post goes out on five networks and fails on the sixth</title>
      <dc:creator>Jonas Nguyen</dc:creator>
      <pubDate>Sun, 25 Oct 2026 15:10:13 +0000</pubDate>
      <link>https://dev.to/jonas_nguyen/when-a-post-goes-out-on-five-networks-and-fails-on-the-sixth-1k71</link>
      <guid>https://dev.to/jonas_nguyen/when-a-post-goes-out-on-five-networks-and-fails-on-the-sixth-1k71</guid>
      <description>&lt;p&gt;When you post to six networks at once, "it worked" and "it failed" are the rare cases. The common one is: it worked on five, and one network said no.&lt;/p&gt;

&lt;p&gt;Maybe a token expired. Maybe the caption was too long for that one network. Maybe the network was having a bad minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: it looked like success
&lt;/h2&gt;

&lt;p&gt;In early versions of Clienzo, such a post was marked Published, because most of it was. The one failure sat inside it, visible only if you opened that post. People did not open it. Why would they? It said Published.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making partial failure visible
&lt;/h2&gt;

&lt;p&gt;So a post that did not go out everywhere now gets flagged as needing attention:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it appears in the &lt;strong&gt;Failed&lt;/strong&gt; tab, even though part of it was published,&lt;/li&gt;
&lt;li&gt;the Posts menu shows a badge until you deal with it,&lt;/li&gt;
&lt;li&gt;you get an &lt;strong&gt;email&lt;/strong&gt; (at most one every 15 minutes, so a bad hour does not flood your inbox),&lt;/li&gt;
&lt;li&gt;and the banner says it plainly: this post did not go out everywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once you have looked at it, &lt;strong&gt;Mark as handled&lt;/strong&gt; clears it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry only what failed
&lt;/h2&gt;

&lt;p&gt;The obvious fix — "post it again" — is wrong, because it would post again on the five networks that already worked.&lt;/p&gt;

&lt;p&gt;Clienzo offers two options instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retry failed&lt;/strong&gt; sends the same post again to only the failed accounts. It works because publishing already skips networks with a successful result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edit &amp;amp; post failed&lt;/strong&gt; makes a new draft containing only the failed accounts, for when the content itself was the problem — say, a caption too long for one network.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One trap I nearly shipped: after a retry, the publish step re-created the analytics record for every account, which would have reset the numbers already measured for the five that went out first. Now it only creates records that do not exist yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why there is no automatic retry
&lt;/h2&gt;

&lt;p&gt;It is tempting to retry automatically. I decided not to. If a network times out but actually accepted the post, an automatic retry posts it twice — on your account, in public. A human pressing Retry after looking is slower, and much safer.&lt;/p&gt;

&lt;p&gt;The one exception is a specific error from Threads that turned out to be temporary; that one is retried three times, a few seconds apart.&lt;/p&gt;

&lt;p&gt;Next: analytics, and why Clienzo shows blanks instead of estimates.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jonasnguyen.com/writing/when-one-network-fails/" rel="noopener noreferrer"&gt;jonasnguyen.com&lt;/a&gt;, where I write about building Clienzo and the next startups.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>product</category>
      <category>reliability</category>
    </item>
    <item>
      <title>Publishing at the right minute, with nothing running in between</title>
      <dc:creator>Jonas Nguyen</dc:creator>
      <pubDate>Fri, 23 Oct 2026 15:10:22 +0000</pubDate>
      <link>https://dev.to/jonas_nguyen/publishing-at-the-right-minute-with-nothing-running-in-between-3d9o</link>
      <guid>https://dev.to/jonas_nguyen/publishing-at-the-right-minute-with-nothing-running-in-between-3d9o</guid>
      <description>&lt;p&gt;A scheduler can get a lot of things slightly wrong and survive. Publishing late is not one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first version: a cron job
&lt;/h2&gt;

&lt;p&gt;The simplest design is a job that wakes up every minute, finds posts that are due, and publishes them. It works, and Clienzo started there.&lt;/p&gt;

&lt;p&gt;Its weaknesses show up with scale. Every minute it asks "is anything due?", mostly to hear "no". When many posts are due at once — 9:00 on a Monday is popular — one run has to work through all of them, and the last one goes out late.&lt;/p&gt;

&lt;h2&gt;
  
  
  One task per post
&lt;/h2&gt;

&lt;p&gt;Today, when you schedule a post, Clienzo creates a queued task for that one post, set to fire at that exact time. The queue (Google Cloud Tasks) wakes the server only when there is something to do, and posts due at the same minute run side by side instead of in a line.&lt;/p&gt;

&lt;p&gt;Publishing runs on its own service, separate from the API the app talks to, so a burst of publishing never slows down the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the cron job is still there
&lt;/h2&gt;

&lt;p&gt;I kept the cron job, now as a safety net. It covers what a queue cannot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;posts scheduled further ahead than the queue accepts,&lt;/li&gt;
&lt;li&gt;posts written straight into the database by scripts,&lt;/li&gt;
&lt;li&gt;a task that failed to be created,&lt;/li&gt;
&lt;li&gt;a publish that crashed halfway and needs to be picked up again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two mechanisms for one job sounds wasteful. In practice it means a missed task costs minutes, not a missed post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Never post twice
&lt;/h2&gt;

&lt;p&gt;The dangerous failure for a scheduler is not missing a post. It is posting it twice. So publishing is written to be safe to repeat: before sending a post to a network, the job checks whether that network already has a successful result, and skips it if so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stream, don't buffer
&lt;/h2&gt;

&lt;p&gt;Videos were the other problem. The first publishers loaded each file into memory before uploading it, which is fine for a photo and not fine for a long video on a small server.&lt;/p&gt;

&lt;p&gt;The publishers now stream media from storage to the network as it is read. In a test with a 600 MB file, memory stayed flat at roughly 80 MB above normal. That test also caught a subtle bug — a download being cancelled because nothing held a reference to it any more — which is exactly why it was worth writing.&lt;/p&gt;

&lt;p&gt;Next: what happens when a post goes out on five networks and fails on the sixth.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jonasnguyen.com/writing/publishing-at-the-right-minute/" rel="noopener noreferrer"&gt;jonasnguyen.com&lt;/a&gt;, where I write about building Clienzo and the next startups.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>architecture</category>
      <category>cloud</category>
    </item>
    <item>
      <title>One composer, sixteen networks: every network has its own rules</title>
      <dc:creator>Jonas Nguyen</dc:creator>
      <pubDate>Wed, 21 Oct 2026 19:54:49 +0000</pubDate>
      <link>https://dev.to/jonas_nguyen/one-composer-sixteen-networks-every-network-has-its-own-rules-5ebf</link>
      <guid>https://dev.to/jonas_nguyen/one-composer-sixteen-networks-every-network-has-its-own-rules-5ebf</guid>
      <description>&lt;p&gt;The pitch of every scheduler is "write once, post everywhere". Building one, I learned it really means "write once, then satisfy sixteen different rulebooks".&lt;/p&gt;

&lt;h2&gt;
  
  
  What the rulebooks look like
&lt;/h2&gt;

&lt;p&gt;A few real examples from Clienzo's networks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Telegram&lt;/strong&gt; cuts a photo caption at 1,024 characters, while a plain text message can run to 4,096.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discord&lt;/strong&gt; messages stop at 2,000 characters, with up to 10 files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bluesky and Mastodon&lt;/strong&gt; take up to four photos, or one video.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dribbble&lt;/strong&gt; takes exactly one image per shot, at a 4:3 size, and the title — which Clienzo takes from the first line of your caption — has a 64-character limit. I found that limit the honest way: a real post came back with an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Viber&lt;/strong&gt; channels want each picture as a JPEG under 1 MB, one picture per message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;YouTube and Dailymotion&lt;/strong&gt; take a video, and need a title too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is unusual. It is just different everywhere, and it changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules live in one place
&lt;/h2&gt;

&lt;p&gt;The first decision was to keep all of those limits in one shared set of post rules, used by the composer in the app, by the public API and by the publishing job. If the composer lets you schedule a post, the API would have accepted it too, and the publisher will not be surprised by it at 3 a.m.&lt;/p&gt;

&lt;p&gt;When a network's rule changes, it changes in one file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix it for the network, not for the user
&lt;/h2&gt;

&lt;p&gt;Some rules can be satisfied without asking you. Clienzo resizes an image to fit Dribbble's 4:3 frame, re-encodes a picture as a JPEG for Viber, and turns hashtags into tags where a network has tags instead.&lt;/p&gt;

&lt;p&gt;Others cannot be guessed: a caption that is too long for one network needs a human to shorten it. Those show up as a clear warning on that network only, before you schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The preview matters more than anything clever
&lt;/h2&gt;

&lt;p&gt;The feature I would keep if I had to drop everything else is the preview per network. It shows the post the way each network will get it: where the caption gets cut, how many images make it, and a note where links in captions are not clickable, as on Instagram and YouTube.&lt;/p&gt;

&lt;p&gt;It is not glamorous. It is the thing that stops a post from going out wrong.&lt;/p&gt;

&lt;p&gt;Next: what happens after you press Schedule, and how a post gets published at the right minute.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jonasnguyen.com/writing/one-composer-sixteen-networks/" rel="noopener noreferrer"&gt;jonasnguyen.com&lt;/a&gt;, where I write about building Clienzo and the next startups.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>product</category>
      <category>integrations</category>
    </item>
  </channel>
</rss>
