<?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: Tony Nguyen</title>
    <description>The latest articles on DEV Community by Tony Nguyen (@tuannx).</description>
    <link>https://dev.to/tuannx</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F272292%2F737ae188-62ef-4e9d-852a-cd89803610ef.jpeg</url>
      <title>DEV Community: Tony Nguyen</title>
      <link>https://dev.to/tuannx</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tuannx"/>
    <language>en</language>
    <item>
      <title>Launching a Telegram Trading Bot in Minutes: DeFiKit Bot Maker Product Walkthrough</title>
      <dc:creator>Tony Nguyen</dc:creator>
      <pubDate>Tue, 09 Jun 2026 05:06:31 +0000</pubDate>
      <link>https://dev.to/tuannx/launching-a-telegram-trading-bot-in-minutes-defikit-bot-maker-product-walkthrough-2apo</link>
      <guid>https://dev.to/tuannx/launching-a-telegram-trading-bot-in-minutes-defikit-bot-maker-product-walkthrough-2apo</guid>
      <description>&lt;p&gt;DeFiKit Bot Maker turns the dream of running your own Telegram trading bot into a five-minute reality — no coding, no DevOps, no blockchain PhD required. This launch brings a fully managed, cloud-native bot builder that lets anyone deploy a production-grade trading bot with wallet integration, real-time market data, and automated strategies straight from a web dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Product Vision — What Problem Does DeFiKit Bot Maker Solve?
&lt;/h2&gt;

&lt;p&gt;Building a Telegram trading bot has historically meant stitching together half a dozen APIs, managing WebSocket connections for price feeds, wrestling with wallet SDKs, and deploying infrastructure that doesn't fall over when memecoin volume spikes. The DeFiKit team saw hundreds of developers and community managers spending weeks on what should be a one-afternoon project.&lt;/p&gt;

&lt;p&gt;DeFiKit Bot Maker eliminates that complexity entirely. Instead of writing glue code between Telegram, a blockchain RPC provider, a price oracle, and a database, users configure their bot through a visual dashboard. The platform handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Connection management&lt;/strong&gt; — WebSocket reconnection, rate limiting, message queueing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wallet orchestration&lt;/strong&gt; — key generation, signing, gas estimation across 12+ chains&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Command routing&lt;/strong&gt; — custom slash commands with parameter validation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State persistence&lt;/strong&gt; — user sessions, portfolio snapshots, trade history&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The core insight: a trading bot's value is in its strategy and user experience, not in the boilerplate infrastructure. DeFiKit Bot Maker commoditises the plumbing so users can focus on what matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Included in the Launch
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Free Tier&lt;/th&gt;
&lt;th&gt;Pro Tier ($29/mo)&lt;/th&gt;
&lt;th&gt;Enterprise (Custom)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bots&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chains&lt;/td&gt;
&lt;td&gt;3 (Ethereum, Base, Solana)&lt;/td&gt;
&lt;td&gt;12 chains&lt;/td&gt;
&lt;td&gt;All supported + custom RPC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strategies&lt;/td&gt;
&lt;td&gt;3 templates&lt;/td&gt;
&lt;td&gt;15 templates + custom&lt;/td&gt;
&lt;td&gt;Unlimited + strategy marketplace&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wallet connections&lt;/td&gt;
&lt;td&gt;1 per bot&lt;/td&gt;
&lt;td&gt;5 per bot&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Historical data&lt;/td&gt;
&lt;td&gt;7 days&lt;/td&gt;
&lt;td&gt;90 days&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Webhook integrations&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Priority support&lt;/td&gt;
&lt;td&gt;Community Discord&lt;/td&gt;
&lt;td&gt;Email + Discord&lt;/td&gt;
&lt;td&gt;Dedicated Slack + SLA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom branding&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;White-label + custom domain&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;What ships with the launch:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bot Builder Dashboard&lt;/strong&gt; — drag-and-drop strategy composer with live preview&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telegram SDK Shim&lt;/strong&gt; — works with existing Telegram groups and channels&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wallet Vault&lt;/strong&gt; — encrypted key management with hardware security module (HSM) backing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-Time Price Feeds&lt;/strong&gt; — sub-second price updates from multiple DEX aggregators&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Command Library&lt;/strong&gt; — 24 pre-built commands (balance, swap, chart, alert, portfolio, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strategy Templates&lt;/strong&gt; — DCA, grid trading, stop-limit, snipe, arbitrage scanner, copy trade&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;DeFiKit Bot Maker runs on Cloudflare Workers and D1, giving it global edge distribution and near-zero cold starts. Here is how the system is structured:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────┐     ┌──────────────────┐     ┌─────────────────┐
│  Telegram   │────▶│  Worker Router   │────▶│  Strategy Engine │
│  User Chat  │     │  (Edge, 40+ loc) │     │  (Durable Obj.)  │
└─────────────┘     └──────────────────┘     └─────────────────┘
                           │                          │
                           ▼                          ▼
                    ┌──────────────┐          ┌──────────────┐
                    │  D1 Database  │          │  Price Feed   │
                    │  (User state,  │          │  WebSocket    │
                    │   config, logs)│          │  (Aggregator)  │
                    └──────────────┘          └──────────────┘
                                                    │
                                                    ▼
                                            ┌──────────────┐
                                            │  RPC Gateway  │
                                            │  (12 chains)   │
                                            └──────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key architectural decisions:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Edge-first routing&lt;/strong&gt;: All Telegram webhooks terminate at the nearest Cloudflare edge location. Messages are validated, rate-limited, and routed to the correct Durable Object shard in under 50ms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Durable Objects for stateful strategies&lt;/strong&gt;: Each active bot gets a Durable Object that maintains its strategy loop, in-memory order book snapshots, and WebSocket connections. This eliminates the need for a separate message broker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;D1 for persistent config&lt;/strong&gt;: User configurations, wallet metadata, and trade history live in D1. Reads are cached at the edge via Workers KV for sub-millisecond access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Price feed layer&lt;/strong&gt;: A dedicated Worker fleet maintains persistent WebSocket connections to DEX aggregators (0x, 1inch, Jupiter). Price updates flow through a fan-out pattern — each update is broadcast to all active strategy DOs that subscribe to that pair.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wallet integration&lt;/strong&gt;: Keys are encrypted at rest using Cloudflare's Web Crypto API with per-user wrapping keys. Signing operations happen inside Durable Objects that never expose the raw private key — only signed transaction hashes leave the secure context.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Getting Started Walkthrough
&lt;/h2&gt;

&lt;p&gt;Here is the exact process to launch your first Telegram trading bot:&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Create Your Bot on Telegram
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Open Telegram, search for @BotFather, and send:&lt;/span&gt;
/newbot
&lt;span class="c"&gt;# Name: My DeFiKit Bot&lt;/span&gt;
&lt;span class="c"&gt;# Username: my_defikit_bot&lt;/span&gt;
&lt;span class="c"&gt;# Save the API token BotFather gives you&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Connect in the DeFiKit Dashboard
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://bot.defikit.io" rel="noopener noreferrer"&gt;https://bot.defikit.io&lt;/a&gt; and sign in with your wallet&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Create New Bot&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Paste your Telegram bot token from BotFather&lt;/li&gt;
&lt;li&gt;Select your target chains (start with Ethereum + Base for testing)&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Step 3: Configure Your First Strategy
&lt;/h3&gt;

&lt;p&gt;From the dashboard, choose &lt;strong&gt;Auto-Snipe&lt;/strong&gt; template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;auto-snipe&lt;/span&gt;
&lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;chain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;base&lt;/span&gt;
  &lt;span class="na"&gt;min_liquidity_usd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50000&lt;/span&gt;
  &lt;span class="na"&gt;max_slippage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5.0&lt;/span&gt;
  &lt;span class="na"&gt;buy_amount_eth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.01&lt;/span&gt;
  &lt;span class="na"&gt;take_profit_pct&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;
  &lt;span class="na"&gt;stop_loss_pct&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
  &lt;span class="na"&gt;anti_rug_checks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;honeypot_scan&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;liquidity_lock&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;renounce_check&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: Fund the Wallet
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# DeployKit generates a deposit address for your bot&lt;/span&gt;
&lt;span class="c"&gt;# Send test funds on Base Sepolia first:&lt;/span&gt;
&lt;span class="c"&gt;# Get test ETH from a Base Sepolia faucet&lt;/span&gt;
&lt;span class="c"&gt;# Send 0.01 ETH to the bot's deposit address&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 5: Launch
&lt;/h3&gt;

&lt;p&gt;Click &lt;strong&gt;Deploy Bot&lt;/strong&gt;. The system:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Registers your webhook with Telegram (

&lt;code&gt;https://bot.defikit.io/webhook/{bot_id}&lt;/code&gt;

)
2. Spins up a Durable Object for your strategy loop
3. Connects to price feeds for your target pairs
4. Sends a "Bot is live!" message to your Telegram chat&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Total time from step 1 to a live bot: &lt;strong&gt;under 5 minutes&lt;/strong&gt;. The dashboard shows real-time logs as your bot starts scanning and executing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-World Use Cases
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Solo Traders&lt;/strong&gt; — Deploy a personal sniper or DCA bot for your own portfolio. Connect your existing MetaMask or Phantom wallet via WalletConnect and let the bot execute strategies while you sleep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Trading Communities&lt;/strong&gt; — Create a community bot for your Telegram group where members can check prices, set alerts, and even contribute to a shared trading pool. The bot supports role-based command permissions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. DeFi Project Owners&lt;/strong&gt; — Launch your own trading competition bot. Custom commands for your token, real-time price charts in chat, and automated buy/sell tracking for community engagement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Alpha Groups&lt;/strong&gt; — Coordinate multi-wallet strategies with the copy-trade template. The bot watches a "master" wallet and mirrors its trades across follower wallets with configurable delay and size multipliers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results &amp;amp; Early Metrics
&lt;/h2&gt;

&lt;p&gt;During private beta (April–June 2026), DeFiKit Bot Maker saw:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;342 bots deployed&lt;/strong&gt; by 187 users in beta&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Median deployment time&lt;/strong&gt;: 4 minutes 23 seconds (from dashboard login to first trade)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Average uptime&lt;/strong&gt;: 99.7% across all bots (measured 30-day rolling)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total trades executed&lt;/strong&gt;: 12,847 with a 94.3% execution success rate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User satisfaction&lt;/strong&gt; (NPS): +62 ("extremely easy to set up" — most common feedback)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One beta user deployed a simple DCA bot on Base that executed 47 automated buys over two weeks without any manual intervention. Another built a community price-check bot that serves 2,300 members across three Telegram groups — all configured in under 10 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;DeFiKit Bot Maker removes the infrastructure tax&lt;/strong&gt; — what used to take a week of backend work now takes minutes in a dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge-native architecture&lt;/strong&gt; makes it globally fast, resilient, and cost-effective — no VM provisioning, no Kubernetes, no DevOps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The template library covers 80% of use cases out of the box&lt;/strong&gt; — snipe, DCA, grid, copy trade, arbitrage scan, and more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wallet security is built in&lt;/strong&gt; — keys never leave the secure Durable Object context, and all signing is air-gapped at the infrastructure level.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Free tier is genuinely useful&lt;/strong&gt; — one bot on three chains is enough to test real strategies before upgrading.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;DeFiKit Bot Maker is available now at &lt;a href="https://bot.defikit.io" rel="noopener noreferrer"&gt;https://bot.defikit.io&lt;/a&gt;. The first bot is free forever. No credit card required.&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Building a Multi-Channel Content Syndication Pipeline with EmDash Plugins</title>
      <dc:creator>Tony Nguyen</dc:creator>
      <pubDate>Mon, 25 May 2026 11:29:12 +0000</pubDate>
      <link>https://dev.to/tuannx/building-a-multi-channel-content-syndication-pipeline-with-emdash-plugins-3934</link>
      <guid>https://dev.to/tuannx/building-a-multi-channel-content-syndication-pipeline-with-emdash-plugins-3934</guid>
      <description>&lt;p&gt;I recently built a content syndication plugin for EmDash that automatically distributes blog posts to Dev.to, LinkedIn, Medium, Hacker News, and email newsletters from a single publish action. Here's how the architecture works and what I learned about multi-platform API orchestration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Format Fragmentation and Timing Drift
&lt;/h2&gt;

&lt;p&gt;Manual cross-posting breaks down in practice because each platform expects different formats and has different constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Format fragmentation&lt;/strong&gt; — HTML on your site, Markdown on Dev.to, rich text on LinkedIn, plain text for HN, HTML for email&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timing drift&lt;/strong&gt; — manual workflows slip by days or weeks, defeating the purpose of coordinated launches&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata mismatch&lt;/strong&gt; — canonical URLs, tags, and excerpts need to be correct per platform for SEO&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No centralized tracking&lt;/strong&gt; — you can't measure which channel drives the most traffic without a unified analytics layer&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;The pipeline runs entirely on Cloudflare Workers via EmDash's plugin system with four components:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Publish Hook&lt;/td&gt;
&lt;td&gt;Trigger on post status change&lt;/td&gt;
&lt;td&gt;EmDash plugin middleware&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Format Renderer&lt;/td&gt;
&lt;td&gt;Convert to platform-specific formats&lt;/td&gt;
&lt;td&gt;Template engine + markdown-it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Channel Adapter&lt;/td&gt;
&lt;td&gt;Platform-specific API client&lt;/td&gt;
&lt;td&gt;Fetch API + OAuth tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queue Manager&lt;/td&gt;
&lt;td&gt;Retry failed syndications&lt;/td&gt;
&lt;td&gt;D1 queue table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analytics Tracker&lt;/td&gt;
&lt;td&gt;Log syndication events&lt;/td&gt;
&lt;td&gt;D1 events table&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No external cron jobs or queue infrastructure needed — Workers' Queues (or KV with TTL) handles the orchestration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: The Publish Hook
&lt;/h2&gt;

&lt;p&gt;The plugin registers a middleware that fires on &lt;code&gt;afterPostSave&lt;/code&gt; when status flips to 'published':&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// emdash-plugin-syndication/hooks.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;afterPostSave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;published&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wasDraft&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SYNDICATION_QUEUE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;`syndicate:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;excerpt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;excerpt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;publishedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;published_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;devto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;linkedin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;medium&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expirationTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key design decision: using &lt;code&gt;post.wasDraft&lt;/code&gt; prevents re-syndication on edits to already-published posts. Without this guard, every content update would re-trigger the pipeline and duplicate content across platforms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Format Adapter Pattern
&lt;/h2&gt;

&lt;p&gt;Each platform gets its own adapter that converts the internal HTML body to the target format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;adapters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;devto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;body_markdown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;htmlToMarkdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;// Dev.to max 4 tags&lt;/span&gt;
    &lt;span class="na"&gt;canonical_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://ai-kit.net/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;published&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;linkedin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;commentator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;urn:li:person:ai-kit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;article&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;excerpt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://ai-kit.net&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;thumbnailUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://ai-kit.net/og/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.png`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;canonicalUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://ai-kit.net/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;distribution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;feedDistribution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MAIN_FEED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;targetEntities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;medium&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;contentFormat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;markdown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;htmlToMarkdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;// Medium max 3 tags&lt;/span&gt;
    &lt;span class="na"&gt;publishStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;canonicalUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://ai-kit.net/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Platform-specific quirks I ran into:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn strips code blocks&lt;/strong&gt; — their rich text API doesn't support &lt;code&gt;&amp;lt;pre&amp;gt;&amp;lt;code&amp;gt;&lt;/code&gt;. I convert code blocks to inline &lt;code&gt;code&lt;/code&gt; spans, which is imperfect but preserves readability&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Medium expects Gist embeds&lt;/strong&gt; for code — plain code fences in Medium import create formatting issues. The adapter optionally wraps code blocks as Gist URLs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dev.to loves code fences&lt;/strong&gt; — standard triple-backtick fences work perfectly with syntax highlighting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hacker News is plain text only&lt;/strong&gt; — 2000 character limit, no markup, no images. Append the original story URL for context&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn API has rate limits&lt;/strong&gt; — 100 posts per 24 hours, ~1 post per 14 minutes sustained&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3: Token Refresh Pattern
&lt;/h2&gt;

&lt;p&gt;OAuth tokens expire and each platform handles expiry differently. Here's the refresh wrapper I built:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getValidToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SECRETS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_token`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expiresAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SECRETS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_expires_at`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;expiresAt&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Refresh token&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;refreshToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SECRETS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_refresh_token`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;refreshAccessToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refreshToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SECRETS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_token`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SECRETS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_expires_at`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expires_in&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha: LinkedIn's access tokens expire every 60 days with no refresh token for the Community Management API. You need the Marketing Developer Platform OAuth 2.0 flow which provides refresh tokens. Dev.to and Medium tokens are long-lived (not expiring), so their &lt;code&gt;expires_at&lt;/code&gt; is set far in the future as a simple sentinel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Error Tracking with D1
&lt;/h2&gt;

&lt;p&gt;Each syndication attempt is logged to a D1 events table for debugging and observability:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;syndication_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;AUTOINCREMENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;post_slug&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;channel&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;-- 'success', 'failed', 'retrying'&lt;/span&gt;
  &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;attempted_at&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;retry_count&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_syndication_post&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;syndication_events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post_slug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipeline retries failed channels up to 3 times with exponential backoff (30s, 2min, 10min). After exhausting retries, the event is marked as permanently failed and a notification is dispatched via Telegram or email.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design Decisions Worth Calling Out
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Staggered syndication timing
&lt;/h3&gt;

&lt;p&gt;I intentionally sequence channels to optimize indexing and avoid spamming overlapping audiences:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Dev.to and Medium first — they index quickly and cache syndicated copies&lt;/li&gt;
&lt;li&gt;LinkedIn next — slower to appear in feeds, so publishing earlier doesn't help&lt;/li&gt;
&lt;li&gt;Email digests last — avoids sending notifications to subscribers who may have already seen the post on another platform&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Rate limiting per channel
&lt;/h3&gt;

&lt;p&gt;Each platform has different API rate limits. Dev.to allows 5 API calls per minute, LinkedIn has daily post limits. The plugin maintains a per-channel rate limiter using D1 counters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkRateLimit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`ratelimit:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SYNDICATION_QUEUE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;RATE_LIMITS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;  &lt;span class="c1"&gt;// { max: 5, window: 60000 }&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RateLimitError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SYNDICATION_QUEUE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expirationTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Canonical URL enforcement
&lt;/h3&gt;

&lt;p&gt;Every syndicated copy includes a &lt;code&gt;canonical_url&lt;/code&gt; pointing back to the original. This is critical for SEO — without it, syndicated copies can outrank the original for search queries. Dev.to and Medium support canonical URLs natively. LinkedIn's article API requires setting &lt;code&gt;canonicalUrl&lt;/code&gt; in the payload.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dry-run mode
&lt;/h3&gt;

&lt;p&gt;Before hitting live APIs, the plugin includes a dry-run mode that returns the formatted payload without posting. You can preview exactly what each platform will receive in EmDash's admin UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dryRun&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;adapter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I'd Do Differently Next Time
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use a message queue from day one&lt;/strong&gt; — I started with simple &lt;code&gt;Promise.all&lt;/code&gt; across all channels. First LinkedIn API timeout blocked Dev.to and Medium. Sequential processing with a proper queue (Workers Queues) fixed this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Platform-specific test fixtures&lt;/strong&gt; — each platform has subtle JSON schema differences that surface as 400s at runtime. Mocking the actual API responses in tests would have caught these earlier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graceful degradation per channel&lt;/strong&gt; — one platform being down shouldn't stop syndication to others. Each channel should be fully isolated in its own Worker invocation.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Metrics to Track
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Syndication velocity&lt;/strong&gt; — time from initial publish to full syndication across all channels (target: under 10 minutes)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Channel performance&lt;/strong&gt; — which syndication channel drives the most referrer traffic back&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failure rate&lt;/strong&gt; — percentage of attempts requiring retries. High failure rates indicate token issues or API changes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SEO impact&lt;/strong&gt; — monitor whether syndicated copies outrank your canonical page. If they do, strengthen canonical tags or delay syndication by 24 hours&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Building a content syndication plugin this way turns a single-platform CMS into a multi-channel distribution engine. Each published post automatically reaches Dev.to's developer audience, LinkedIn's professional network, Medium's general readership, Hacker News's tech community, and email subscribers — with zero manual effort after the initial publish click.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>How We Built a Plugin Marketplace That Sells Itself: Embed Sales Architecture in EmDash</title>
      <dc:creator>Tony Nguyen</dc:creator>
      <pubDate>Sat, 23 May 2026 05:03:55 +0000</pubDate>
      <link>https://dev.to/tuannx/how-we-built-a-plugin-marketplace-that-sells-itself-embed-sales-architecture-in-emdash-238p</link>
      <guid>https://dev.to/tuannx/how-we-built-a-plugin-marketplace-that-sells-itself-embed-sales-architecture-in-emdash-238p</guid>
      <description>&lt;p&gt;Every integration is a storefront. When a third-party platform embeds an EmDash plugin, their entire user base gains a one-click path to discover us — turning each integration partner into an always-on sales channel with zero marginal cost. Here's how we built the architecture that makes this work, and the tradeoffs we made along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture at a Glance
&lt;/h2&gt;

&lt;p&gt;The embed sales channel rests on three systems: the &lt;strong&gt;Plugin API&lt;/strong&gt; (what plugin authors build against), the &lt;strong&gt;Embed Registry&lt;/strong&gt; (how we map plugins to third-party surfaces), and the &lt;strong&gt;Discovery Layer&lt;/strong&gt; (the user-facing badge that drives conversion). Each is independently versioned, and we treat backward compatibility as a first-class concern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plugin API: The Contract
&lt;/h2&gt;

&lt;p&gt;Every plugin starts as a manifest file that declares its identity and capabilities. We chose a declarative manifest over a purely programmatic API because it lets the Embed Registry reason about compatibility at install time rather than runtime.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// plugin-manifest.json&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;apiVersion&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;v2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;emDash-ai-form-automation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AI Form Automation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;version&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2.1.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;targetPlatform&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gravity-forms&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;minHostVersion&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2.5.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;capabilities&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;embed:toolbar-button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;embed:admin-panel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webhook:form-submit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webhook:ai-response&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;permissions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;read:form-schemas&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;write:form-entries&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;read:user-context&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;runtime&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sandbox&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shadow-dom&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;maxPayloadBytes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;524288&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why Declarative?
&lt;/h3&gt;

&lt;p&gt;We started with a purely imperative API — registerPlugin({...}) — and quickly hit versioning nightmares. Plugin authors would call APIs that didn't exist yet in older hosts, or rely on side effects we'd removed. By moving to a manifest-first model, the Embed Registry can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reject incompatible plugins pre-install&lt;/strong&gt;: If minHostVersion exceeds the host's current version, the user gets a clear message instead of a runtime crash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Selectively enable capabilities&lt;/strong&gt;: If a host only supports embed:toolbar-button but the plugin requests embed:admin-panel, the registry grants the subset that works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit security boundaries&lt;/strong&gt;: permissions are declared upfront and checked against the host's security policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Tradeoff&lt;/strong&gt;: The manifest adds friction for simple plugins. A plugin that just needs a single toolbar button still has to declare capabilities, permissions, and runtime. We experimented with sensible defaults (if you omit capabilities, we assume embed:toolbar-button) but found that explicit declarations caught too many misconfigurations to drop them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Embed Registry: The Mapping Layer
&lt;/h2&gt;

&lt;p&gt;When a user installs a plugin targeting Gravity Forms, the Embed Registry generates an embed manifest — a JSON document that tells the host exactly how to surface the plugin.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"embedManifest"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"plugin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"emDash-ai-form-automation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gravity-forms"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"entryPoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://em.dash/embed/gf-automation.js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"integrity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sha384-abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"mountStrategy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shadow-dom"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"styles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"injection"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"scoped"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"theme"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inherit"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lifecycle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"onMount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"load"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"onUnmount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"destroy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"idleTimeoutMs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300000&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"discoveryBadge"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"position"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"toolbar"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"variant"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"minimal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Automated by EmDash"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The integrity hash is non-negotiable — every embed script is subresource-integrity-checked. We learned this the hard way after an early prototype didn't hash its embeds and a compromised CDN could have injected malicious badges. Shadow DOM isolation (mountStrategy: shadow-dom) ensures plugin styles can't leak into or out of the host page, which is critical when you're embedding into production admin panels.&lt;/p&gt;

&lt;h3&gt;
  
  
  Version Negotiation
&lt;/h3&gt;

&lt;p&gt;This is where it gets tricky. The host (say, Gravity Forms v3.0) might support a different set of embed features than the plugin expects. We handle this with a version negotiation handshake:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified negotiation flow&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;negotiateEmbed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plugin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Plugin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HostContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;EmbedManifest&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hostCapabilities&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCapabilities&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// Intersect plugin capabilities with host capabilities&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supported&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;plugin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;capabilities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;hostCapabilities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;supported&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Graceful degradation — show a fallback link&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createFallbackManifest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plugin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Pick the richest supported capability&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bestCapability&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;prioritizeByRichness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;supported&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;generateManifest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plugin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bestCapability&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If no capabilities are supported, we fall back to a simple link embed — the plugin still shows up, just as a text link instead of a rich interactive panel. This graceful degradation over hard failure policy has been our most important reliability decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Model
&lt;/h2&gt;

&lt;p&gt;Third-party embeds are a security minefield. Here's our threat model and how we address each vector:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Threat&lt;/th&gt;
&lt;th&gt;Mitigation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;XSS via plugin script&lt;/td&gt;
&lt;td&gt;Subresource integrity + Content Security Policy nonce&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Style leakage&lt;/td&gt;
&lt;td&gt;Shadow DOM isolation (scoped styles)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data exfiltration&lt;/td&gt;
&lt;td&gt;Permission system enforced at registry level, not just plugin side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clickjacking&lt;/td&gt;
&lt;td&gt;frame-ancestors CSP directive + X-Frame-Options: SAMEORIGIN on all non-embed endpoints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supply chain (compromised plugin update)&lt;/td&gt;
&lt;td&gt;Signed manifests + version pinning in host config&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Tradeoff&lt;/strong&gt;: The permission system is coarse. A plugin that requests read:form-schemas gets access to all form schemas, not just the ones it needs. We considered a more granular model (per-field scoping, regex-based selectors) but it added enormous complexity to both the registry and the plugin API. For v1, coarse permissions with manual review for sensitive plugins was the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Embed SDK
&lt;/h2&gt;

&lt;p&gt;Partners integrate our embeds via a tiny SDK (~3KB gzipped). The SDK handles authentication, lifecycle, and analytics so plugin authors don't have to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Partner integration — one import&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://em.dash/sdk/embed.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;createEmbed&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;createEmbed&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-platform&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;emDash-automation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;emDash-content-gen&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;oauth-device&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="c1"&gt;// No tokens stored in localStorage — we use session cookies via iframe&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;placement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin-toolbar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key decision: &lt;strong&gt;no iframes for the primary embed&lt;/strong&gt;. We prototyped with iframes (easy isolation, trivial to implement) but they break keyboard navigation, create focus-trapping issues, and make responsive layouts painful. Shadow DOM with scoped styles gives us the same isolation without the UX debt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Version everything, and version it explicitly.&lt;/strong&gt; We shipped apiVersion: v1 without a minHostVersion field. Within two releases, plugin authors were calling methods that didn't exist on older hosts, and users got cryptic errors. Adding explicit version constraints felt bureaucratic but eliminated an entire class of support tickets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Graceful degradation is a feature, not a fallback.&lt;/strong&gt; Early on, if a host didn't support a plugin's required capability, we'd refuse to install. This meant users on slightly older versions of a platform couldn't use any EmDash plugins at all. The fallback-to-link behavior — while less rich — means zero users get a hard not compatible message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Shadow DOM is not free.&lt;/strong&gt; Each shadow root adds memory overhead, and deeply nested shadow trees (plugin inside host panel inside admin dashboard) can impact paint performance. We cap nesting at one level and recommend hosts mount embeds at the document root rather than inside existing shadow trees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Attribution is harder than it looks.&lt;/strong&gt; The ref=embed parameter is straightforward, but cross-origin attribution (user sees badge on Platform A, clicks, then signs up via Platform B's embed later) requires either first-party cookies (privacy-hostile) or probabilistic matching (noisy). We settled on anonymous impression IDs stored in session storage, which means we lose attribution on browser restarts. It's a deliberate tradeoff for privacy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;We're working on hot-reloadable plugins (replace a plugin's runtime without requiring re-installation) and a sandboxed WebAssembly runtime for plugins that need CPU-intensive operations without blocking the host thread. The manifest format will get a runtime.wasm field alongside the existing runtime.sandbox options.&lt;/p&gt;

&lt;p&gt;If you're building a plugin marketplace, the architecture described above — declarative manifests, capability intersection, graceful degradation, and a permission-based security model — has held up well across 12 integration partners. Start with the top 10 platforms your users already use, build deep integrations with graceful fallbacks, and treat your Plugin API as the product it is.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>Startup tools: very amazing complicated system but by prompting - Firebase Studio, Claude code</title>
      <dc:creator>Tony Nguyen</dc:creator>
      <pubDate>Thu, 03 Jul 2025 13:40:32 +0000</pubDate>
      <link>https://dev.to/tuannx/startup-tools-very-amazing-complicated-system-but-by-prompting-firebase-studio-claude-code-1kk1</link>
      <guid>https://dev.to/tuannx/startup-tools-very-amazing-complicated-system-but-by-prompting-firebase-studio-claude-code-1kk1</guid>
      <description>&lt;p&gt;&lt;em&gt;This post is my submission for &lt;a href="https://dev.to/deved/build-apps-with-google-ai-studio"&gt;DEV Education Track: Build Apps with Google AI Studio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Startup Ascent - AI-Powered Startup Guidance Platform&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I Built&lt;/strong&gt;&lt;br&gt;
I built Startup Ascent, a comprehensive AI-driven platform that guides early-stage startup founders through the critical phases of building a business, from idea validation to launch and growth. The platform combines gamification, AI-powered tools, and structured learning to create a complete ecosystem for entrepreneurs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key AI Prompts Used:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"Analyze this startup idea and provide a SWOT analysis, potential risks, and validation steps"&lt;br&gt;
"Generate a comprehensive pitch deck outline for this startup concept"&lt;br&gt;
"Create customer personas based on this target audience description"&lt;br&gt;
"Process this knowledge resource and generate summary, mind map, and key insights"&lt;br&gt;
"Moderate this content for safety and appropriateness in a professional startup community"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Core Features Utilized:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Multi-format AI processing (documents, websites, videos, audio)&lt;br&gt;
AI knowledge base with intelligent search&lt;br&gt;
Gamified progress tracking with XP, levels, and quests&lt;br&gt;
Community-driven resource sharing with AI moderation&lt;br&gt;
Template marketplace for startup idea generation&lt;/p&gt;

&lt;p&gt;Demo&lt;br&gt;
Live Platform: &lt;a href="https://startupascent.net/" rel="noopener noreferrer"&gt;https://startupascent.net/&lt;/a&gt;&lt;br&gt;
Test Credentials:&lt;/p&gt;

&lt;p&gt;Email: &lt;a href="mailto:test@startupascent.net"&gt;test@startupascent.net&lt;/a&gt;&lt;br&gt;
Password: Test123!@#&lt;/p&gt;

&lt;p&gt;Key Screenshots:&lt;br&gt;
🎮 Gamified Dashboard&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftkshklya2n73vnc0xzg7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftkshklya2n73vnc0xzg7.png" alt="startupascent.net-Gamified Dashboard" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🚀 Template Marketplace&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo8qg69of2466alnp4mdi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo8qg69of2466alnp4mdi.png" alt="startupascent.net-Template Marketplace" width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🧠 AI-Powered Tools Suite&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1nqq1y8h1p77hwx1fneh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1nqq1y8h1p77hwx1fneh.png" alt="startupascent.net-AI-Powered Tools Suite" width="800" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📚 Enhanced Knowledge Base&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fimdxkexrdxhscuzgtij1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fimdxkexrdxhscuzgtij1.png" alt="startupascent.net-Enhanced Knowledge Base" width="800" height="662"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📱 Responsive Design&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv054h92c1tv0g1dz7v4y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv054h92c1tv0g1dz7v4y.png" alt="startupascent.net-Responsive Design" width="800" height="1732"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And continue building...&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My Experience&lt;/strong&gt;&lt;br&gt;
Key Takeaways&lt;br&gt;
AI Integration Complexity: Building a production-ready AI system taught me that the real challenge isn't calling AI APIs—it's creating robust, secure, and user-friendly experiences around them. I implemented comprehensive content moderation, input validation, and error handling that most AI demos skip.&lt;br&gt;
Gamification Drives Engagement: The XP/quest system wasn't just a gimmick—it fundamentally changed how users interact with the platform. By breaking down the overwhelming startup journey into achievable quests, users stay motivated and make consistent progress.&lt;br&gt;
Community + AI = Powerful Synergy: The combination of AI-generated insights and community-shared resources created a knowledge base far more valuable than either component alone. Users contribute real-world examples while AI provides structured analysis.&lt;br&gt;
What Was Surprising&lt;br&gt;
AI Content Moderation Is Essential: I initially underestimated the need for content safety. Implementing AI-powered moderation with confidence scoring and detailed feedback became crucial for maintaining platform quality and user trust.&lt;br&gt;
Multi-Format Processing Complexity: Supporting documents, websites, videos, and audio required different extraction strategies, but the unified AI processing pipeline made diverse content equally searchable and useful.&lt;br&gt;
Users Want Structure, Not Just Tools: Rather than building individual AI tools, the integrated approach with guided stages, progress tracking, and contextual assistance proved much more valuable for actual startup success.&lt;br&gt;
Real-World Impact: Seeing users actually validate ideas, build MVPs, and launch products using the platform's guidance system validated that AI can genuinely accelerate entrepreneurship when properly structured.&lt;br&gt;
Technical Learnings&lt;/p&gt;

&lt;p&gt;Progressive Enhancement: Building core functionality that works without AI, then enhancing with intelligent features, created a more reliable user experience&lt;/p&gt;

&lt;p&gt;The platform demonstrates that AI's true power in business applications comes not from replacing human judgment, but from augmenting human capabilities with intelligent automation, community insights, and structured guidance.&lt;/p&gt;

</description>
      <category>deved</category>
      <category>learngoogleaistudio</category>
      <category>ai</category>
      <category>gemini</category>
    </item>
  </channel>
</rss>
