<?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: Eugeniya Ivanova</title>
    <description>The latest articles on DEV Community by Eugeniya Ivanova (@eugeniya_ivanova_4a58eadc).</description>
    <link>https://dev.to/eugeniya_ivanova_4a58eadc</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%2F4017574%2F67a20934-fb30-41ef-bfcc-79a85f515c48.jpg</url>
      <title>DEV Community: Eugeniya Ivanova</title>
      <link>https://dev.to/eugeniya_ivanova_4a58eadc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/eugeniya_ivanova_4a58eadc"/>
    <language>en</language>
    <item>
      <title>My MCP integration got rejected. Almost nothing in the server had to change.</title>
      <dc:creator>Eugeniya Ivanova</dc:creator>
      <pubDate>Mon, 07 Sep 2026 07:54:22 +0000</pubDate>
      <link>https://dev.to/eugeniya_ivanova_4a58eadc/my-mcp-integration-got-rejected-almost-nothing-in-the-server-had-to-change-npb</link>
      <guid>https://dev.to/eugeniya_ivanova_4a58eadc/my-mcp-integration-got-rejected-almost-nothing-in-the-server-had-to-change-npb</guid>
      <description>&lt;p&gt;In July I set out to add our MCP server to the ChatGPT app directory. The server was already live, running in Claude and Cursor, with OAuth working. Submitting looked like a formality: describe what already exists, fill in the application, hit Submit.&lt;/p&gt;

&lt;p&gt;A month and one rejection later, almost nothing had changed in the server itself. It had been working the whole time. What we ended up fixing was everything around it: how ChatGPT discovers the tools, what the scanner expects, what we'd written in the descriptions, and whether a reviewer could actually log in.&lt;/p&gt;

&lt;p&gt;Here's what caught me.&lt;/p&gt;

&lt;h3&gt;
  
  
  The tool list that freezes solid
&lt;/h3&gt;

&lt;p&gt;The first problem came before submission. I connected the server to ChatGPT — the connector was created, but it showed "no actions available." Zero tools. In Claude and Cursor the same tools loaded fine.&lt;/p&gt;

&lt;p&gt;I spent a while looking at SSE and caching. Neither was the problem. ChatGPT reads the tool list when the app is created, and if the server returns zero at that point, the app can get stuck there. "Refresh" is disabled at zero and "Save" is greyed out. In my case, the way out was to delete the app and create it again.&lt;/p&gt;

&lt;p&gt;The server returned zero because it required a token for &lt;code&gt;tools/list&lt;/code&gt; itself. Claude logs in first and fetches the list afterwards, which is why we'd never seen the problem there. ChatGPT fetches the list before authorization, gets nothing, and turns to stone. So now I check one thing before doing anything else: &lt;code&gt;tools/list&lt;/code&gt; needs to return 200 with the tools and no token.&lt;/p&gt;

&lt;h3&gt;
  
  
  Annotations the scanner demands and the spec doesn't
&lt;/h3&gt;

&lt;p&gt;Next, OpenAI's scanner went through every tool and required explicit &lt;code&gt;readOnlyHint&lt;/code&gt;, &lt;code&gt;openWorldHint&lt;/code&gt; and &lt;code&gt;destructiveHint&lt;/code&gt; values. Four read-only tools didn't have &lt;code&gt;destructiveHint&lt;/code&gt;, which made sense according to &lt;a href="https://modelcontextprotocol.io/specification/2025-06-18/server/tools" rel="noopener noreferrer"&gt;the MCP spec&lt;/a&gt;: it only matters when &lt;code&gt;readOnlyHint: false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The scanner wanted it anyway. We added &lt;code&gt;destructiveHint: false&lt;/code&gt; to those tools and wrote justifications for forty-odd annotation values. The tools themselves didn't change. We just had to make explicit something the spec allowed us to leave implied.&lt;/p&gt;

&lt;h3&gt;
  
  
  The domain challenge that wants a bare string and lives forever
&lt;/h3&gt;

&lt;p&gt;A small one that's easy to waste time on: OpenAI's domain verification expects the endpoint to return a bare token string. No JSON, no quotes. Return &lt;code&gt;{"token": "..."}&lt;/code&gt; and it fails.&lt;/p&gt;

&lt;p&gt;It also isn't a one-time verification endpoint. It has to stay there in production, answering the same way afterwards. So we now have a tiny endpoint with one permanent job: return a string when OpenAI asks.&lt;/p&gt;

&lt;h3&gt;
  
  
  The drift I caught on myself
&lt;/h3&gt;

&lt;p&gt;Then I found something that actually was our mistake.&lt;/p&gt;

&lt;p&gt;Our public &lt;a href="https://github.com/publora-team/mcp-server" rel="noopener noreferrer"&gt;&lt;code&gt;mcp-server&lt;/code&gt; repo&lt;/a&gt; defines 18 tools, four of them for LinkedIn analytics: post stats, account stats, followers and profile summary. The live server exposes 16, and none of those analytics tools are among them. They exist in the REST API, but aren't exposed through MCP.&lt;/p&gt;

&lt;p&gt;The public repo had fallen behind the live server, and the app description was based on the repo. So we were describing analytics that someone using MCP couldn't actually access. I removed them from the description and release notes.&lt;/p&gt;

&lt;p&gt;Then I found the same old description in two more places: the README for the Zed extension and the PR for the Docker MCP Catalog. The Zed PR was also pointing at a commit from before the correction, so merging it would have put the outdated claim into another catalog. We'd updated the product and missed a few places where we'd described it. Easy enough to do when those descriptions live in different repos and submissions.&lt;/p&gt;

&lt;h3&gt;
  
  
  The rejection that wasn't about the code
&lt;/h3&gt;

&lt;p&gt;Submitted August 5, every wizard step completed in one pass. Rejected August 24.&lt;/p&gt;

&lt;p&gt;The message was: "We're unable to complete your sign-in or OAuth flow… ensure valid, working credentials… no additional setup or verification."&lt;/p&gt;

&lt;p&gt;Before changing anything, I walked through the flow again. Dynamic client registration returned 201. &lt;code&gt;/authorize&lt;/code&gt; sent me to our consent screen. The sign-in page in a clean browser was a normal email-and-password form with no captcha. &lt;code&gt;tools/list&lt;/code&gt; without a key returned the tools. API and MCP access were enabled on every plan, including free. Everything worked.&lt;/p&gt;

&lt;p&gt;Then we looked at the account we'd given the reviewer.&lt;/p&gt;

&lt;p&gt;Our own accounts use Google sign-in. A reviewer can't use our Google account, and even if they tried, the second factor would land on our phone. We'd managed to give them credentials for an account they had no way to get into.&lt;/p&gt;

&lt;p&gt;Even worse, Canva had rejected us for the same reason before. Twice was enough to stop calling it bad luck. We simply didn't have "can a stranger actually use these credentials?" on our submission checklist.&lt;/p&gt;

&lt;h3&gt;
  
  
  What actually fixed it
&lt;/h3&gt;

&lt;p&gt;We created a separate reviewer account: email and password, email confirmed ahead of time, a couple of channels connected, with some posts and drafts already there. We also included eight test cases — five positive and three negative.&lt;/p&gt;

&lt;p&gt;The most useful one was a full write-path test through &lt;code&gt;publora-playground&lt;/code&gt;. It's a fake publishing target: it validates the post against the same rules as a real one and returns the same kind of response, but throws the request away at the end instead of publishing it anywhere. A reviewer can go through the whole publishing flow without putting a test post on someone's actual timeline.&lt;/p&gt;

&lt;p&gt;It took about thirty lines and ended up being one of the most useful things we added for the submission. The reviewer could finally test writes without needing a disposable social account.&lt;/p&gt;

&lt;p&gt;We resubmitted on the evening of August 24. &lt;a href="https://chatgpt.com/plugins/plugin_asdk_app_6a7074e91c008191942479b885fb0d9b" rel="noopener noreferrer"&gt;Approved September 4.&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What I wrote down for next time
&lt;/h3&gt;

&lt;p&gt;The main addition to my submission checklist is pretty basic: go through the product as the reviewer, not as yourself.&lt;/p&gt;

&lt;p&gt;The server worked in Claude and Cursor. It followed the spec. OAuth worked when we tested it. But ChatGPT asked for the tool list in a different order, its scanner expected metadata we hadn't needed elsewhere, and the reviewer needed a login that didn't depend on being us. Those were the things that took the month.&lt;/p&gt;




&lt;p&gt;I did this with Claude — it wrote the code and configs, while I checked what was happening and decided what needed changing. The funny part is that almost none of those decisions involved changing the server itself. Most of the work ended up being metadata, descriptions and access.&lt;/p&gt;

&lt;p&gt;What's gotten your product bounced from a platform review — something actually broken, or something you never thought a reviewer would run into?&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I sell social media automation. My survey says people want the opposite.</title>
      <dc:creator>Eugeniya Ivanova</dc:creator>
      <pubDate>Thu, 03 Sep 2026 08:07:39 +0000</pubDate>
      <link>https://dev.to/eugeniya_ivanova_4a58eadc/i-sell-social-media-automation-my-survey-says-people-want-the-opposite-13e1</link>
      <guid>https://dev.to/eugeniya_ivanova_4a58eadc/i-sell-social-media-automation-my-survey-says-people-want-the-opposite-13e1</guid>
      <description>&lt;p&gt;I work at &lt;a href="https://publora.com" rel="noopener noreferrer"&gt;Publora&lt;/a&gt; — an API and MCP for publishing to social networks from one place, including through an AI agent. So, in a sense, my job is selling automation for posting. Which made the answers I started getting from actual people a little awkward.&lt;/p&gt;

&lt;p&gt;I've been doing a small study — nothing fancy, just messaging people directly and asking: what do you use to post, and why do you do it that way? No pitch, no setup. It's an ongoing thing, reaching out to people across different niches one by one, so this isn't a clean statistic — more a pile of honest answers I'm still adding to. A few hundred replies in, and I'm still going.&lt;/p&gt;

&lt;p&gt;The answers are all over the place, but one thing keeps coming up. People aren't asking for more automation. Quite a lot of them want to keep the final step for themselves.&lt;/p&gt;

&lt;p&gt;A few answers, anonymized.&lt;/p&gt;

&lt;p&gt;One person told me: "I use AI rarely, do the core work myself. Sometimes I go into ChatGPT or Gemini as a 'second brain' — when I'm out of ideas or a deadline's closing in. But at the end of the day it's still just a machine."&lt;/p&gt;

&lt;p&gt;So AI is useful to think with. That doesn't mean they want it doing everything.&lt;/p&gt;

&lt;p&gt;Another: "I use Claude, but not for posting — it sort of sets me up, and I publish myself."&lt;/p&gt;

&lt;p&gt;And a third was very direct: "I'm wary of AI tools having access to my account. LinkedIn isn't forgiving about it."&lt;/p&gt;

&lt;p&gt;That one wasn't really about whether AI-written posts sound authentic. They simply didn't want another tool having that much access to their account.&lt;/p&gt;

&lt;p&gt;Then there was this: "I like hitting publish myself. In a world where everything's polished, it makes the content feel a little more alive."&lt;/p&gt;

&lt;p&gt;Different reasons: sometimes security, sometimes control, sometimes just liking the fact that there's still a person involved. But I kept hearing versions of the same thing. Draft for me, help me think, prepare it — fine. I'll publish it.&lt;/p&gt;

&lt;p&gt;Nobody was dreaming about an agent happily posting all day while they went for coffee.&lt;/p&gt;

&lt;p&gt;At first this was slightly uncomfortable to hear, considering what I do for a living. But the more replies I read, the less it sounded like an objection to automation. It sounded more like people being quite specific about which parts they actually want automated.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where that caution stops being a quirk
&lt;/h3&gt;

&lt;p&gt;One answer stood out because it came from someone managing several brands rather than just their own account.&lt;/p&gt;

&lt;p&gt;They prefer tools with integrations because content and reporting are easier there than doing everything natively. The problem is that not every platform has the integrations they need, and the more channels a brand uses, the messier the whole setup gets.&lt;/p&gt;

&lt;p&gt;That changes the problem quite a bit.&lt;/p&gt;

&lt;p&gt;Being cautious about giving AI access to your own LinkedIn account is one thing. Being an agency responsible for ten clients' accounts is another. Now you're dealing with reputations that aren't yours, client credentials, several calendars, approvals living somewhere in email, and the possibility of someone putting one client's post on another client's account.&lt;/p&gt;

&lt;p&gt;At that point, automating everything sounds terrifying. Doing everything manually isn't much better. Ten clients still have to fit into the same working day.&lt;/p&gt;

&lt;p&gt;So you need something in between.&lt;/p&gt;

&lt;h3&gt;
  
  
  What that looks like in practice
&lt;/h3&gt;

&lt;p&gt;After reading the replies, I kept coming back to three things.&lt;/p&gt;

&lt;p&gt;The first is keeping clients separate. Each one needs their own connections, content and calendar, without the possibility of a post quietly ending up on somebody else's account. A posting mistake on your own account is embarrassing. Doing it to a client's account can get expensive.&lt;/p&gt;

&lt;p&gt;The second is passwords. Ideally, an agency shouldn't have them at all. The client connects their account through an OAuth link, and the team can work with it without ever knowing the login. The person who told me they were wary of tools having access to their account wasn't being paranoid. That's a perfectly reasonable thing to worry about.&lt;/p&gt;

&lt;p&gt;And then there's approval. An agent can draft a post. It can prepare it, schedule it, do the repetitive bits. But publishing can still wait for a person to approve it. In a team, that can also mean the person who wrote the post isn't the person who approves it.&lt;/p&gt;

&lt;p&gt;It's basically the same habit I kept seeing in the survey, only with more people involved.&lt;/p&gt;

&lt;p&gt;We build this at Publora — it's our &lt;a href="https://agencies.publora.com" rel="noopener noreferrer"&gt;workspace for agencies&lt;/a&gt;, and the API is &lt;a href="https://docs.publora.com" rel="noopener noreferrer"&gt;documented here&lt;/a&gt;. I won't turn this into a tour of the product; if that's what you're looking for, the links are there.&lt;/p&gt;

&lt;p&gt;What's been more interesting for me is realizing that some of the things we'd been thinking about as product features map almost exactly to things people told me they were worried about.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I took from it
&lt;/h3&gt;

&lt;p&gt;Automation is usually sold as: let the machine do this for you.&lt;/p&gt;

&lt;p&gt;But after reading these replies, I think there's a pretty big group of people asking for something slightly different: help me do it, just don't take the whole thing away from me.&lt;/p&gt;

&lt;p&gt;For someone posting to their own account, that might simply mean wanting to hit Publish themselves. For an agency, the same instinct turns into much more practical requirements: keep clients separate, don't pass passwords around, and make sure a person approves what goes out.&lt;/p&gt;

&lt;p&gt;I find it slightly funny that it took working inside an automation company to make me think harder about what shouldn't be automated.&lt;/p&gt;




&lt;p&gt;And for the record, I wrote this with AI. It helped me sort through material and clean up the draft. Deciding what was interesting in a few hundred replies was still my job.&lt;/p&gt;

&lt;p&gt;I'm keeping that bit.&lt;/p&gt;

&lt;p&gt;Where do you draw the line? What are you happy to hand to an agent, and what do you still want to do yourself?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>socialmedia</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I published our app on Zapier. The no-code platform made me write code.</title>
      <dc:creator>Eugeniya Ivanova</dc:creator>
      <pubDate>Mon, 31 Aug 2026 06:29:04 +0000</pubDate>
      <link>https://dev.to/eugeniya_ivanova_4a58eadc/i-published-our-app-on-zapier-the-no-code-platform-made-me-write-code-2hc3</link>
      <guid>https://dev.to/eugeniya_ivanova_4a58eadc/i-published-our-app-on-zapier-the-no-code-platform-made-me-write-code-2hc3</guid>
      <description>&lt;p&gt;Publora is in the Zapier app directory now. I didn't do it to tick a box on some distribution list. My job is making our product easy to live with, and if a user has an agent that can wire us in deeper so they don't have to build the plumbing themselves, I'll go make that happen. Zapier is exactly that case: it connects Publora to thousands of other apps, so nobody has to hand-roll the integration.&lt;/p&gt;

&lt;p&gt;Worth it. I'd just add that "a no-code platform" and "publishing your own app on a no-code platform" turn out to be two very different Zapiers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prove it works for users who don't exist yet
&lt;/h3&gt;

&lt;p&gt;Here's the requirement I reread three times, sure I'd misunderstood.&lt;/p&gt;

&lt;p&gt;To submit an app for review, every trigger, every action, and every search has to be tested inside a live Zap, turned on, with at least one successful run in the history. You can't delete those Zaps; the reviewer can ask to see them.&lt;/p&gt;

&lt;p&gt;So the logic goes like this. You want to publish an app so people can start using it. But to publish it, you first have to prove it's already being used. Run every component for real, as if you had the users you're publishing it to attract. The app isn't in the directory yet, and a history of real use already has to exist.&lt;/p&gt;

&lt;p&gt;You end up standing in for your own users who aren't there yet. You build the Zaps, run each one, make sure every one has a green run, and don't touch them afterward.&lt;/p&gt;

&lt;h3&gt;
  
  
  A routine task you run like a rocket launch
&lt;/h3&gt;

&lt;p&gt;The second surprise. My tasks here are the plain ones: schedule a post, publish a post, delete a post. This isn't a satellite launch. It's what our API does a thousand times a day over one line of code.&lt;/p&gt;

&lt;p&gt;As a Zapier app, each of those ordinary tasks has to be wrapped, configured, and run live on its own. Create Post, Update Post, Delete Post, two triggers, two searches, each with its own test run under the validator's eye. Scheduling a post is something I can describe in one sentence. Here it became a component with a run history.&lt;/p&gt;

&lt;p&gt;Then the small surprises a "no-code" promise doesn't quite imply. The connection label, the line that tells the user which account they connected, couldn't just read a field. &lt;code&gt;{{connections.0.username}}&lt;/code&gt; doesn't work, because Zapier won't follow a path into an array. So the label needs actual JavaScript:&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;connections&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bundle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inputData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connections&lt;/span&gt; &lt;span class="o"&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;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;connections&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="o"&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="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Publora&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's in a box labeled "label." On a no-code platform.&lt;/p&gt;

&lt;p&gt;The triggers are the other place the word stretches. These aren't polling. They're REST Hooks on our own webhooks, which means subscribing and unsubscribing are API calls you define by hand:&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;// subscribe&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;url&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://api.publora.com/api/v1/webhooks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&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;Content-Type&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;application/json&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;Accept&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;application/json&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;x-publora-key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bundle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&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="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Zapier - Post Published&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bundle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;targetUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;events&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;post.published&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&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="nf"&gt;then&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="o"&gt;=&amp;gt;&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="nf"&gt;throwForStatus&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;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhook&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;What subscribe returns is not incidental. Zapier hands it back to you as &lt;code&gt;bundle.subscribeData&lt;/code&gt;, and unsubscribe reads the webhook id out of it to call &lt;code&gt;DELETE /webhooks/{id}&lt;/code&gt;. Return the wrong shape and the Zap turns on cleanly, then never cleans up after itself.&lt;/p&gt;

&lt;p&gt;A few more edges, for anyone about to do this. A field's type and key are locked after you create it, and if you get the shape of a step wrong and you don't fix it, you recreate it from scratch. The first published version gets locked for editing once real people are on it, so you clone it into a new one and make changes there. And the editor session expires with a cheerful &lt;code&gt;failed csrf&lt;/code&gt;; you reload, and the code you just wrote is gone.&lt;/p&gt;

&lt;p&gt;None of it is fatal. I just walked in with a one-line task and walked out with versioning, user migrations, and a chunk of JavaScript in a field called "label."&lt;/p&gt;

&lt;h3&gt;
  
  
  Where it landed
&lt;/h3&gt;

&lt;p&gt;The app passed review after eight rounds of reviewer notes, then out into the directory with a Beta tag. Zapier's beta runs ninety days, and you leave it one of two ways: pull in enough active users, or embed their widget inside your own product. Familiar shape. The platform holds you at "not quite yet," and the key out is either bring it an audience or let it inside your product.&lt;/p&gt;

&lt;p&gt;I don't regret the trip. The directory listing genuinely lowers the effort for anyone who wants to post from whatever workflow they already live in, and I wrote them a separate tutorial for that. But the "no-code" part I left somewhere around the halfway mark. For the user assembling a Zap out of ready-made blocks, sure, no code. For the person making those blocks, no-code ends exactly where anything past a single step begins.&lt;/p&gt;




&lt;p&gt;For the record, I'm not an engineer. My job is getting people to use our product, and I built this with Claude: it wrote the code, I led, tested, and sent it back to redo. Which is maybe the honest shape of "no-code" in 2026 anyway. The code didn't disappear. It just moved to someone else, or something else.&lt;/p&gt;

&lt;p&gt;Did your no-code platforms keep the promise when you went past a single step? Or did you end up opening the code editor too?&lt;/p&gt;

</description>
      <category>zapier</category>
      <category>nocode</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The agent posted successfully. To the wrong account.</title>
      <dc:creator>Eugeniya Ivanova</dc:creator>
      <pubDate>Thu, 27 Aug 2026 08:31:54 +0000</pubDate>
      <link>https://dev.to/eugeniya_ivanova_4a58eadc/the-agent-posted-successfully-to-the-wrong-account-3kf3</link>
      <guid>https://dev.to/eugeniya_ivanova_4a58eadc/the-agent-posted-successfully-to-the-wrong-account-3kf3</guid>
      <description>&lt;p&gt;Back in July I wrote about &lt;a href="https://dev.to/eugeniya_ivanova_4a58eadc/why-every-ai-agent-eventually-fights-social-media-apis-35e0"&gt;what it takes to wire an AI agent into social platforms&lt;/a&gt;: six OAuth flows, three-step media uploads, tokens expiring on their own private schedules. The conclusion was to hide all of it behind a single tool call and stop looking at it.&lt;/p&gt;

&lt;p&gt;Two months on, that part is done. Our MCP server answers over OAuth now, with proper metadata at &lt;code&gt;/.well-known/oauth-authorization-server&lt;/code&gt;, PKCE, and dynamic client registration, so connecting an editor no longer involves typing a key into a config file. Sixteen tools, one endpoint. The plumbing works.&lt;/p&gt;

&lt;p&gt;What I got wrong was assuming the plumbing was the risky part.&lt;/p&gt;

&lt;p&gt;My job is getting our product into people's hands, which means I use it the way I hope other people will: I ask an agent to publish and go back to what I was doing. Living with that for a couple of months taught me that once an agent has write access, failures stop announcing themselves.&lt;/p&gt;

&lt;h3&gt;
  
  
  An identifier that looks exactly like an identifier
&lt;/h3&gt;

&lt;p&gt;A language model produces plausible-looking strings. That is the entire skill. Ask it to post to LinkedIn and it can hand the API a value with the right prefix, the right length, the right shape, and the wrong account.&lt;/p&gt;

&lt;p&gt;Nothing about that request is malformed. There is no error to catch. The API was asked to do something specific and it did it.&lt;/p&gt;

&lt;p&gt;So the first instruction our server gives any client is not a description of what it does. It's a rule: call &lt;code&gt;list_connections&lt;/code&gt; first, copy each &lt;code&gt;platformId&lt;/code&gt; verbatim, never invent one. Before the tool list, before the examples, before anything explaining what the product is for.&lt;/p&gt;

&lt;p&gt;Writing documentation for a reader who will confidently improvise is a genuinely different job from writing it for a human who will get bored and skim.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Tomorrow at 9am" is a timezone question
&lt;/h3&gt;

&lt;p&gt;The API takes ISO 8601 in UTC and nothing else. So when you say "tomorrow at 9am," something has to decide which 9am you meant, and that something is the model.&lt;/p&gt;

&lt;p&gt;It's right most of the time. When it isn't, nothing surfaces at the call. The response is a normal success, the post sits in the queue with a perfectly valid timestamp, and you find out at 4am from the post itself.&lt;/p&gt;

&lt;p&gt;I now read the scheduled time back in the confirmation. Not because the model is bad at arithmetic, but because a wrong answer here is indistinguishable from a right one until it's too late to matter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Accepted now, dead later
&lt;/h3&gt;

&lt;p&gt;Instagram, TikTok and YouTube won't publish without media. Nothing stops an agent from scheduling a text-only Instagram post: it validates, it enters the queue, it sits there looking healthy for a day, and it dies at publish time.&lt;/p&gt;

&lt;p&gt;Queue membership is not a promise. It's the kind of distinction you only learn by getting burned, because until then the failure is completely invisible.&lt;/p&gt;

&lt;h3&gt;
  
  
  The annotations nobody looks at
&lt;/h3&gt;

&lt;p&gt;MCP lets a server tag each tool with hints about what it does: &lt;code&gt;readOnlyHint&lt;/code&gt;, &lt;code&gt;destructiveHint&lt;/code&gt;. Ours are filled in. Sixteen tools, six of them flagged destructive: deleting posts, deleting media, removing a LinkedIn comment or reaction.&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"delete_post"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"annotations"&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;"readOnlyHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destructiveHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;They're advisory. A client can ignore them entirely, and plenty do. But they cost almost nothing to add, and they're the only way a server can tell a client "this one deserves a confirmation dialog" without inventing a private protocol. If you run an MCP server and haven't filled them in, that's twenty minutes of work that lets every well-behaved client protect your users for you.&lt;/p&gt;

&lt;h3&gt;
  
  
  A fake platform to post into
&lt;/h3&gt;

&lt;p&gt;The fix I like most is the least clever one:&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;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"platforms"&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="s2"&gt;"publora-playground"&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;It accepts the post, validates it against the real rules, returns a normal response, and throws it away. Nothing reaches a real network.&lt;/p&gt;

&lt;p&gt;It exists because there was previously no honest way to answer "is this connected and working?" Every genuine end-to-end test involved putting something real on someone's real timeline, which is a fine way to test at 2am and an awful one at any other hour. Now the whole round trip is testable without an audience.&lt;/p&gt;

&lt;p&gt;Every integration that writes somewhere public should have one of these, and most don't.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I'd tell July
&lt;/h3&gt;

&lt;p&gt;I'm biased about the product, so here's the part that isn't about it.&lt;/p&gt;

&lt;p&gt;When you give an agent write access to anything outward-facing, the failure worth designing against is not the 500. Exceptions land in logs and somebody eventually reads them. The dangerous one is the call that succeeds and quietly does the wrong thing: right shape, wrong target, no error anywhere in the chain.&lt;/p&gt;

&lt;p&gt;The July version of this was "hide the complexity behind one tool call." I still think that's right. I'd just add the second half now: and make the tool call hard to get subtly wrong, because subtly wrong is the only kind of wrong that gets published.&lt;/p&gt;

&lt;p&gt;Posting from the terminal saved me a context switch. The guardrails are what made me willing to leave it running while I did something else.&lt;/p&gt;




&lt;p&gt;I drafted this with Claude and then checked every claim against the live server before publishing. The playground response, the tool annotations, and the OAuth metadata are all things I re-ran rather than remembered.&lt;/p&gt;

&lt;p&gt;If you run an agent with write access to production, where's your line: a dry-run target, tool annotations, or a human confirming every call?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>mcp</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I built plugins for three editors. Everywhere, you're a guest in someone else's house</title>
      <dc:creator>Eugeniya Ivanova</dc:creator>
      <pubDate>Wed, 26 Aug 2026 06:34:45 +0000</pubDate>
      <link>https://dev.to/eugeniya_ivanova_4a58eadc/i-built-plugins-for-three-editors-everywhere-youre-a-guest-in-someone-elses-house-34ck</link>
      <guid>https://dev.to/eugeniya_ivanova_4a58eadc/i-built-plugins-for-three-editors-everywhere-youre-a-guest-in-someone-elses-house-34ck</guid>
      <description>&lt;p&gt;Over the last while I've built integrations for three places where people work with text and images: &lt;a href="https://dev.to/eugeniya_ivanova_4a58eadc/i-shipped-a-plugin-with-one-branch-i-hadnt-tested-so-i-built-a-fallback-lc8"&gt;Obsidian&lt;/a&gt;, VS Code, and Figma. Doing a few of them back to back, I noticed something you don't see from a single one.&lt;/p&gt;

&lt;p&gt;They're all desktop apps. For your integration to exist at all, the person first installs a program on their machine, and then, inside it, your plugin. You're not writing for the web. You're writing code locked inside someone else's app — and each app has its own runtime, its own rules, and its own wall for you to walk into. The web trained us to think an HTTP request is one line. Inside someone else's sandbox, it turns out even that has to be earned.&lt;/p&gt;

&lt;p&gt;Figma was the strictest host of the three. I'll tell it through Figma, because it's locked down tighter than Obsidian or VS Code, and everything shows up on it at once.&lt;/p&gt;

&lt;p&gt;The task was almost comically simple: select a frame, write a caption, pick your social accounts, publish — without exporting the image and opening a second app. We already had the publishing API, so I expected the Figma side to be small. And it was: the main plugin file is 120 lines. The work wasn't in them. It was around them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Figma gives you bytes, not a file
&lt;/h3&gt;

&lt;p&gt;The first version came together easily. When the selection changes, the plugin checks whether there's one exportable node and tells the UI what it found. For the preview it exports a small copy; for publishing, separately, at 2×.&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;bytes&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;nodes&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="nf"&gt;exportAsync&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PNG&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;constraint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SCALE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&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;2× because the image still has a journey ahead of it: social networks recompress what you upload, and small text on a design goes noticeably softer by the time it lands in a feed.&lt;/p&gt;

&lt;p&gt;Then the first quirk of the foreign house. Figma hands the plugin not a file but raw PNG bytes — &lt;a href="https://www.figma.com/plugin-docs/api/properties/nodes-exportasync/" rel="noopener noreferrer"&gt;&lt;code&gt;exportAsync()&lt;/code&gt;&lt;/a&gt; returns a &lt;code&gt;Uint8Array&lt;/code&gt;. Our normal API won't eat that — it doesn't take a giant image stuffed into a JSON body. It creates a post first, hands the client a temporary upload URL, waits for the file to reach storage, and only then attaches the media to the post.&lt;/p&gt;

&lt;p&gt;So "publish one frame with a caption" turned into this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;create post → get upload URL → PUT PNG → complete media → update post
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a post with no image, it's one call. With an image, the post lives as a draft while the upload happens, and moves to its final state only once the media is there. That order saves you from a particularly annoying failure: scheduling a post, feeling pleased, then learning the image never uploaded.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two worlds inside one plugin
&lt;/h3&gt;

&lt;p&gt;A Figma plugin isn't one JavaScript. &lt;a href="https://www.figma.com/plugin-docs/how-plugins-run/" rel="noopener noreferrer"&gt;The main thread&lt;/a&gt; sees the document — that's where we read the selection and call &lt;code&gt;exportAsync()&lt;/code&gt;. Network calls live in the UI, a separate context. Between them, a wall.&lt;/p&gt;

&lt;p&gt;So the exported &lt;code&gt;Uint8Array&lt;/code&gt; has to be thrown from the main thread over to the panel before anything can upload it. Convert it to a plain array, send it with &lt;code&gt;postMessage&lt;/code&gt;, reassemble the bytes on the other side.&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;// main thread&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&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;exportSelection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;figma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;exported&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// UI&lt;/span&gt;
&lt;span class="nx"&gt;exportedBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not hard. Just one of those details that vanishes without a trace when you picture the feature in your head as "export the frame and send it to the API."&lt;/p&gt;

&lt;p&gt;The next detail didn't let go so easily. The plugin UI runs in a &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Origin" rel="noopener noreferrer"&gt;sandboxed iframe&lt;/a&gt; with &lt;code&gt;Origin: null&lt;/code&gt;. Our API, naturally, wasn't set up to accept browser requests from &lt;code&gt;null&lt;/code&gt; — so the code worked right up to the point where it was supposed to do something useful. The classic: all green until it reaches the part that matters.&lt;/p&gt;

&lt;p&gt;I ended up putting a small Cloudflare Worker between the plugin and the API. It forwards requests and adds the CORS headers Figma insists on. It doesn't store the key or add a second auth system — the key sits in &lt;a href="https://www.figma.com/plugin-docs/api/figma-clientStorage/" rel="noopener noreferrer"&gt;&lt;code&gt;figma.clientStorage&lt;/code&gt;&lt;/a&gt; and just gets passed through to Publora when the plugin makes a request.&lt;/p&gt;

&lt;p&gt;We already had a similar worker for our Canva integration, but I made a separate one for Figma. Canva was on review at the time, and shipping new routes into infrastructure a reviewer might be poking at right then was a bad trade for saving one tiny worker.&lt;/p&gt;

&lt;h3&gt;
  
  
  A composer stops being simple very fast
&lt;/h3&gt;

&lt;p&gt;Once the requests finally worked, the boring per-platform rules came back.&lt;/p&gt;

&lt;p&gt;The plugin pulls the accounts already connected to Publora and shows them with usernames and avatars. You can pick several — which immediately raises the question of which limit the character counter should count against.&lt;/p&gt;

&lt;p&gt;LinkedIn allows far more text than X. If both are checked, showing the LinkedIn limit is useless when X is going to reject the post anyway. So the counter uses the strictest limit among the selected networks.&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;function&lt;/span&gt; &lt;span class="nf"&gt;captionLimit&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;limits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;CAPTION_LIMITS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;platformOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)])&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;limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&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;limits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;min&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;limits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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;Media works the same way. Publora won't take a text-only post for Instagram, TikTok, or YouTube. If one of those is selected and there isn't exactly one frame selected in Figma, the plugin says so and disables Publish instead of waiting for the API to object.&lt;/p&gt;

&lt;p&gt;There are a handful of small cases like that in the UI. Two selected frames don't make two posts; the plugin asks you to pick one. A custom schedule time isn't valid until you've chosen a date. After a successful send, the form is replaced with a confirmation rather than leaving an active Publish button sitting there, a double post one impatient click away.&lt;/p&gt;

&lt;p&gt;Nothing impressive in that code. It just took more time than getting &lt;code&gt;exportAsync()&lt;/code&gt; to work.&lt;/p&gt;

&lt;h3&gt;
  
  
  And then you have to submit it
&lt;/h3&gt;

&lt;p&gt;The other half of "make a Figma plugin" is making something Figma will actually list.&lt;/p&gt;

&lt;p&gt;Because ours talks to an external service, the Community description says it outright: you need a Publora account, there's a free plan, the key is stored on the user's machine. Privacy policy and support contact are right there too.&lt;/p&gt;

&lt;p&gt;The manifest has to be specific about &lt;a href="https://www.figma.com/plugin-docs/manifest/#networkaccess" rel="noopener noreferrer"&gt;network access&lt;/a&gt;. Ours lists the worker, Publora's media domain, and the storage hosts that receive the exported PNG. No wildcard for "whatever the upload API hands me next."&lt;/p&gt;

&lt;p&gt;And we kept the listing strictly about what the plugin does in Figma. No stray product features, no mention of MCP. Figma has its own rules about MCP access to files, and it isn't needed here anyway: the plugin takes a design out of Figma and sends it where the user chose.&lt;/p&gt;

&lt;p&gt;Before submitting, we ran the plugin in desktop Figma on a live Publora account. It connected, pulled the account list with its avatar, applied the LinkedIn limit, and created a real draft we confirmed through the API. The branch I trusted least was the image path — it crosses nearly every boundary in the plugin at once: Figma export, &lt;code&gt;postMessage&lt;/code&gt;, the UI, the worker, the API, storage. That's the one I watched hardest.&lt;/p&gt;

&lt;p&gt;It's on review now, so I don't have a tidy "and it was approved in six minutes" ending. Figma doesn't promise a fixed time either, so the last step of the build is glancing at the status occasionally and leaving it alone.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it adds up to
&lt;/h3&gt;

&lt;p&gt;The funny part is that the Figma-specific code really was small. Those 120 lines do almost exactly what I pictured at the start. What turned the feature into a project was everything between "I have PNG bytes" and "this can safely become a social post."&lt;/p&gt;

&lt;p&gt;And it isn't only Figma. Obsidian made me go through its own &lt;a href="https://docs.obsidian.md/Reference/TypeScript+API/requestUrl" rel="noopener noreferrer"&gt;&lt;code&gt;requestUrl&lt;/code&gt;&lt;/a&gt; for network calls, because plain fetch hits the engine's limits on mobile. VS Code has its own ways. Figma has two contexts and &lt;code&gt;Origin: null&lt;/code&gt;. Every time, you take the same task — "send a post" — and rewrite it for a different sandbox, because you're a guest in someone else's app, not at home on the web.&lt;/p&gt;

&lt;p&gt;Whether it's worth it is a separate question. People work in these editors, and meeting them where they already are is more honest than dragging them over to you. But every one of those visits is a new runtime, a new wall, and a new worker to route around somebody's sandbox. I've done three, and I think I'm only starting to understand what I signed up for.&lt;/p&gt;




&lt;p&gt;I built this plugin with Claude — it wrote most of the code. The decisions, the testing, and what to do at each wall were mine.&lt;/p&gt;

&lt;p&gt;When your user lives inside someone else's desktop app, which way do you go: climb in there with them, or pull them out to the browser and into yours?&lt;/p&gt;

</description>
      <category>figma</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>plugins</category>
    </item>
    <item>
      <title>I shipped a plugin with one branch I hadn't tested — so I built a fallback</title>
      <dc:creator>Eugeniya Ivanova</dc:creator>
      <pubDate>Thu, 20 Aug 2026 08:30:58 +0000</pubDate>
      <link>https://dev.to/eugeniya_ivanova_4a58eadc/i-shipped-a-plugin-with-one-branch-i-hadnt-tested-so-i-built-a-fallback-lc8</link>
      <guid>https://dev.to/eugeniya_ivanova_4a58eadc/i-shipped-a-plugin-with-one-branch-i-hadnt-tested-so-i-built-a-fallback-lc8</guid>
      <description>&lt;p&gt;I wrote the Obsidian plugin with Claude Code: it typed the code; the rules, reviews, and decisions were mine. I led, argued with it, redid things, and submitted the plugin for review myself. I'm not an engineer. My job is getting people to actually use our product.&lt;/p&gt;

&lt;p&gt;The plugin itself is simple: one &lt;code&gt;main.js&lt;/code&gt;, 557 lines, zero dependencies, &lt;a href="https://docs.obsidian.md/Reference/TypeScript+API/Plugin" rel="noopener noreferrer"&gt;Obsidian API&lt;/a&gt; only. It takes the note you have open and sends it to your social accounts on a schedule.&lt;/p&gt;

&lt;p&gt;Most of the evening, though, went into getting it through the Obsidian catalog. Here's what I tripped over, so hopefully you don't have to.&lt;/p&gt;

&lt;h3&gt;
  
  
  Six fixes the Obsidian review asked for
&lt;/h3&gt;

&lt;p&gt;Before a plugin lands in the catalog, it gets reviewed against the &lt;a href="https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines" rel="noopener noreferrer"&gt;Plugin guidelines&lt;/a&gt;. I read them beforehand and still came back with notes. Some requirements are much easier to notice once a reviewer points directly at them.&lt;/p&gt;

&lt;p&gt;These were my six:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;innerHTML&lt;/code&gt;.&lt;/strong&gt; Build the DOM with &lt;code&gt;createEl&lt;/code&gt;. They check for this because &lt;code&gt;innerHTML&lt;/code&gt; plus third-party text is an open door.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Styles go in &lt;code&gt;styles.css&lt;/code&gt;,&lt;/strong&gt; not inline in JS. Inline styles got bounced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Modal titles use &lt;code&gt;titleEl&lt;/code&gt;,&lt;/strong&gt; not your own heading inside the modal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No top-level heading in settings,&lt;/strong&gt; and use sentence case, not Title Case Across Every Word.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drop the plugin name from command titles.&lt;/strong&gt; Obsidian adds the prefix itself; otherwise the command palette shows &lt;code&gt;Publora: Publora: Send note&lt;/code&gt;. It stutters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network requests go through &lt;a href="https://docs.obsidian.md/Reference/TypeScript+API/requestUrl" rel="noopener noreferrer"&gt;&lt;code&gt;requestUrl&lt;/code&gt;&lt;/a&gt;.&lt;/strong&gt; Plain &lt;code&gt;fetch&lt;/code&gt; runs into problems on mobile, so if the plugin is &lt;code&gt;isDesktopOnly: false&lt;/code&gt;, use Obsidian's API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this changed what the plugin did. But doing it upfront would have saved me a review round and a couple of days.&lt;/p&gt;

&lt;h3&gt;
  
  
  A portal that won't explain its own errors
&lt;/h3&gt;

&lt;p&gt;Submission goes through &lt;a href="https://community.obsidian.md/" rel="noopener noreferrer"&gt;community.obsidian.md&lt;/a&gt;: sign in with your Obsidian account, then connect GitHub separately. The happy path is documented. The potholes aren't.&lt;/p&gt;

&lt;p&gt;Mine:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"You do not own this repository" — when you clearly have access.&lt;/strong&gt; If the repo belongs to an organization, choose the organization as the submission owner instead of &lt;code&gt;Myself&lt;/code&gt;. The error disappeared immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your GitHub organization membership has to be public.&lt;/strong&gt; The portal only sees public members. None of ours were public, so as far as the portal was concerned, our organization contained approximately nobody.&lt;/p&gt;

&lt;p&gt;One request fixes your membership:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PUT /orgs/{org}/public_members/{username}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catch: you can only make your &lt;em&gt;own&lt;/em&gt; membership public this way. &lt;a href="https://docs.github.com/en/rest/orgs/members#set-public-organization-membership-for-the-authenticated-user" rel="noopener noreferrer"&gt;Each person has to do it with their own token.&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rate limiter.&lt;/strong&gt; After several attempts, the portal starts replying with &lt;code&gt;Please wait before trying again&lt;/code&gt;. It doesn't say how long, and clicking again only makes things worse.&lt;/p&gt;

&lt;p&gt;The solution turned out to be extremely technical: leave it alone and come back later. Then click once.&lt;/p&gt;

&lt;h3&gt;
  
  
  Proving the release files came from your code
&lt;/h3&gt;

&lt;p&gt;The review passed, but there was one note left: the release files had no artifact attestation.&lt;/p&gt;

&lt;p&gt;If you haven't run into this before, the problem is pretty straightforward. Users don't install the source code they're looking at in your GitHub repository. They install the built &lt;code&gt;main.js&lt;/code&gt; attached to a GitHub Release.&lt;/p&gt;

&lt;p&gt;Those two files &lt;em&gt;can&lt;/em&gt; be different.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds" rel="noopener noreferrer"&gt;Artifact attestation&lt;/a&gt; lets someone verify where the release file came from. GitHub Actions, through &lt;a href="https://www.sigstore.dev/" rel="noopener noreferrer"&gt;Sigstore&lt;/a&gt;, ties the artifact to a specific commit and workflow. This is the &lt;code&gt;release.yml&lt;/code&gt; I ended up with — it also checks the tag matches the manifest version before it publishes:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Release&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;*'&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;attestations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Check the tag matches the manifest&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;tag="${GITHUB_REF_NAME}"&lt;/span&gt;
          &lt;span class="s"&gt;manifest="$(node -p "require('./manifest.json').version")"&lt;/span&gt;
          &lt;span class="s"&gt;if [ "$tag" != "$manifest" ]; then&lt;/span&gt;
            &lt;span class="s"&gt;echo "Tag $tag does not match manifest version $manifest"&lt;/span&gt;
            &lt;span class="s"&gt;exit 1&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Attest the release assets&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/attest-build-provenance@v2&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;subject-path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;main.js&lt;/span&gt;
            &lt;span class="s"&gt;manifest.json&lt;/span&gt;
            &lt;span class="s"&gt;styles.css&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Publish the release&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.token }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;gh release create "${GITHUB_REF_NAME}" \&lt;/span&gt;
            &lt;span class="s"&gt;main.js manifest.json styles.css \&lt;/span&gt;
            &lt;span class="s"&gt;--title "${GITHUB_REF_NAME}" \&lt;/span&gt;
            &lt;span class="s"&gt;--generate-notes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The part that cost me the most time was these two permissions:&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;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
&lt;span class="na"&gt;attestations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without them, my workflow went green but no attestation appeared.&lt;/p&gt;

&lt;p&gt;Everything looked fine. Everything was not fine.&lt;/p&gt;

&lt;p&gt;Once I added the permissions, the check actually passed and &lt;code&gt;verified GitHub artifact attestation&lt;/code&gt; showed up in the review.&lt;/p&gt;

&lt;p&gt;This is probably my favorite part of the whole process: nobody has to take your word for it. The release itself carries proof of where it came from.&lt;/p&gt;

&lt;h3&gt;
  
  
  The branch I hadn't tested
&lt;/h3&gt;

&lt;p&gt;At submission time, I still had one thing I hadn't managed to verify.&lt;/p&gt;

&lt;p&gt;Login in the plugin goes through OAuth, so the user gets a token. Our REST API, meanwhile, had historically been used with an API key.&lt;/p&gt;

&lt;p&gt;Would the API accept the token from this new OAuth flow?&lt;/p&gt;

&lt;p&gt;Probably.&lt;/p&gt;

&lt;p&gt;Had I actually tested it?&lt;/p&gt;

&lt;p&gt;No.&lt;/p&gt;

&lt;p&gt;I could ship it and find out from the first person whose button stopped working. People do this more often than conference talks would have you believe.&lt;/p&gt;

&lt;p&gt;Instead, I built a fallback. The plugin sends the credential — whether that's the signed-in token or a pasted key — in the same &lt;code&gt;x-publora-key&lt;/code&gt; header. If the request comes back &lt;code&gt;401&lt;/code&gt; and the token was the one that failed, it retries once with the API key from settings instead of stranding the user mid-post. If there's no key to fall back to, it says so in plain words rather than leaving a dead button:&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;callApi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&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="nx"&gt;plugin&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;credential&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;plugin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apiKey&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;)&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Not connected yet. Open Settings, then Publora, and press Connect.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;usedToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;plugin&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;oauth&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;credential&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;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;oauth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accessToken&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;requestUrl&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BASE_URL&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&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;x-publora-key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&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;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&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;body&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="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;throw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;response&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="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// The signed-in token was refused. If a key is also configured, use it&lt;/span&gt;
    &lt;span class="c1"&gt;// rather than stranding the user mid-post.&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;usedToken&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apiKey&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="nf"&gt;callApi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;({},&lt;/span&gt; &lt;span class="nx"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;oauth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;usedToken&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Publora refused the signed-in account. Reconnect in Settings → Publora, or paste an API key there under Advanced.&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;Publora rejected the API key. Check it in Settings → Publora.&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="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;json&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;The retry is just the same function calling itself with &lt;code&gt;oauth&lt;/code&gt; nulled out, so it falls through to the key. A week later, working on OAuth for another add-on, I finally got to test the real thing: the REST API accepts the token fine, and the fallback never fired.&lt;/p&gt;

&lt;p&gt;But I didn't know that when I submitted the plugin.&lt;/p&gt;

&lt;p&gt;That's the bit I want to keep from this whole exercise. Sometimes you have a branch you can't test before release. You don't have to pretend otherwise. If the failure mode is predictable and the fallback is cheap, you can put the uncertainty into the program instead of handing it to the user.&lt;/p&gt;

&lt;h3&gt;
  
  
  Numbers I actually trust
&lt;/h3&gt;

&lt;p&gt;As of August 19: 33 installs.&lt;/p&gt;

&lt;p&gt;Not a lot.&lt;/p&gt;

&lt;p&gt;But the version distribution shows most of those installs on the latest version, so people are updating rather than installing once and disappearing.&lt;/p&gt;

&lt;p&gt;For comparison, our extension on another marketplace shows 772 "downloads." That counter also eats mirrors and editor caches. At one point it jumped by 26 in half an hour when basically nobody knew the extension existed.&lt;/p&gt;

&lt;p&gt;So right now I'll take the 33.&lt;/p&gt;

&lt;p&gt;At least I know what they mean.&lt;/p&gt;




&lt;p&gt;I built this plugin with AI, and I'm not particularly interested in hiding that. Claude Code typed most of it. I decided what it should do, reviewed what it produced, dealt with the submission, and decided what to do when I couldn't verify something before release.&lt;/p&gt;

&lt;p&gt;The typing is increasingly the easy part.&lt;/p&gt;

&lt;p&gt;The annoying part is still noticing the thing you haven't tested.&lt;/p&gt;

&lt;p&gt;What do you do when you reach release with one branch still uncertain: ship it and wait for the bug report, or build the fallback first?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>opensource</category>
      <category>obsidian</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I got our API into the Postman network in one evening — and didn't hand-write a single request</title>
      <dc:creator>Eugeniya Ivanova</dc:creator>
      <pubDate>Tue, 18 Aug 2026 07:30:59 +0000</pubDate>
      <link>https://dev.to/eugeniya_ivanova_4a58eadc/i-got-our-api-into-the-postman-network-in-one-evening-and-didnt-hand-write-a-single-request-2an6</link>
      <guid>https://dev.to/eugeniya_ivanova_4a58eadc/i-got-our-api-into-the-postman-network-in-one-evening-and-didnt-hand-write-a-single-request-2an6</guid>
      <description>&lt;p&gt;About a quarter of our users access the API with a key. The main barrier isn't code, it's the first half hour: open the docs, figure out which endpoints you need, put together the first request, find where the key goes. The Postman network skips most of that. You get ready-made collections you can fork into your workspace and run with your own key. We're in there now, next to Notion, Stripe, and Twilio — and getting listed took one evening. Here's how I did it, and why I decided not to build the collection by hand.&lt;/p&gt;

&lt;p&gt;First, a pleasant surprise. Getting listed anywhere usually means sitting in someone's review queue — Chrome, Edge, and LinkedIn have all made us wait. Postman doesn't work that way: it's self-serve, publishing is free, and there's no application or moderation. So the whole thing really did fit into an evening.&lt;/p&gt;

&lt;p&gt;You can build a collection for the network by hand: create the requests, type in the paths, fields, and headers. Plenty of people do. The problem is that a hand-built collection is a snapshot of your API on the day you made it. Add an endpoint or rename a field a month later, and now the collection is out of date.&lt;/p&gt;

&lt;p&gt;So I didn't write ours. I imported our OpenAPI spec. Ours is public and has 29 paths:&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="err"&gt;https://docs.publora.com/openapi.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The import is one call to the Postman API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="s1"&gt;'https://api.getpostman.com/import/openapi'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'X-Api-Key: PMAK-YOUR-KEY'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "type": "json",
    "options": { "folderStrategy": "Path" },
    "input": &amp;lt;contents of openapi.json&amp;gt;
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That means the paths, fields, and types come straight from the spec and match the actual API. When the API changes, you can regenerate the collection from the latest spec instead of manually syncing everything again.&lt;/p&gt;

&lt;p&gt;There's one thing to watch for. Notice &lt;code&gt;folderStrategy: "Path"&lt;/code&gt; — Postman organizes the imported requests by URL path. That's technically correct, but not particularly nice to browse: you end up with a wall of paths instead of useful groups. So importing was only half the job. I reorganized the collection through &lt;code&gt;PUT /collections/{uid}&lt;/code&gt; into something a person can actually navigate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Connections   3   connected accounts, platform limits, connection test
Posts         6   create, schedule, get, update, delete, logs
Media         5   three-step file upload, Reels cover, YouTube thumbnail
LinkedIn      9   analytics, followers, comments, reactions, repost
Webhooks      5   notifications instead of polling
Workspace     6   managing client accounts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A note about keys, since this is a public catalog rather than my private workspace. Authentication came from the spec automatically: the &lt;code&gt;x-publora-key&lt;/code&gt; header gets its value from an &lt;code&gt;apiKey&lt;/code&gt; variable, and that variable is empty. Anyone who forks the collection adds their own key. I checked the published version to make sure mine wasn't there. And if you're doing this yourself, don't put your Postman key (&lt;code&gt;PMAK-…&lt;/code&gt;) in the collection either. You need it for the import call, not for the public collection.&lt;/p&gt;

&lt;p&gt;Instead of writing a long introduction, I added a short "first post in 60 seconds" and a "where everyone trips" section. That felt more useful than repeating the docs. It covers the things we see people get wrong: copy the &lt;code&gt;platformId&lt;/code&gt; exactly or the post can go to the wrong account; Instagram and TikTok don't accept text-only posts; leave out the publish time and you get a draft rather than a published post; comments are LinkedIn-only; and you can test the whole flow without publishing anything by using the &lt;code&gt;publora-playground&lt;/code&gt; target.&lt;/p&gt;

&lt;p&gt;Two things tripped me up, so they're worth mentioning.&lt;/p&gt;

&lt;p&gt;Workspace visibility can't be changed through the API. You can do almost everything else in code, but switching the workspace from Internal to Public has to be done manually in settings — in the same place where you rename the team. Not a big deal once you know, but I spent some time looking for an API setting that doesn't exist.&lt;/p&gt;

&lt;p&gt;And one more general lesson: trust the machine-readable spec over the overview page. Overview docs can lag behind the API. The spec is much more likely to reflect what's actually implemented. If I'm wiring up an integration against someone else's API, that's where I'd look first.&lt;/p&gt;

&lt;p&gt;The short version: getting into the Postman network can take an evening, and the useful shortcut is not writing the collection by hand. Generate it from the spec, clean up the structure, leave the key field empty, and regenerating it after API changes becomes much easier.&lt;/p&gt;

&lt;p&gt;If you're in Postman and want to have a look, here's our collection: &lt;a href="https://www.postman.com/publora/publora-api/overview" rel="noopener noreferrer"&gt;Publora API on Postman&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For those publishing your own APIs: do you generate collections from the spec or still build them by hand? And how often do you find your overview docs have drifted from the actual spec?&lt;/p&gt;

</description>
      <category>api</category>
      <category>postman</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>We shipped a one-click 'Add to Cursor' button. The hard part wasn't the button.</title>
      <dc:creator>Eugeniya Ivanova</dc:creator>
      <pubDate>Mon, 10 Aug 2026 12:17:01 +0000</pubDate>
      <link>https://dev.to/eugeniya_ivanova_4a58eadc/we-shipped-a-one-click-add-to-cursor-button-the-hard-part-wasnt-the-button-141m</link>
      <guid>https://dev.to/eugeniya_ivanova_4a58eadc/we-shipped-a-one-click-add-to-cursor-button-the-hard-part-wasnt-the-button-141m</guid>
      <description>&lt;p&gt;My job is getting our product into people's hands. It's built for AI agents, so it has to live where agents pick up their tools — in their catalogs. Slow business: we're in the review queue at the big ones as I write this. And getting listed is only half the job. The other half is making sure connecting isn't a scavenger hunt through config files, pasted keys, and hopeful restarts.&lt;/p&gt;

&lt;p&gt;So we started with a button. One click, Publora's in Cursor. Sounds like an evening's work.&lt;/p&gt;

&lt;p&gt;It wasn't.&lt;/p&gt;

&lt;p&gt;Cursor installs MCP servers from a deeplink — a link that tells the editor to add a given server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cursor://anysphere.cursor-deeplink/mcp/install?name=Publora&amp;amp;config=&amp;lt;base64&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The config is a base64-encoded JSON holding the server address. Official format, everyone uses it, Asana and Xano included. We built the button, the link generated cleanly, the base64 decoded into exactly the right config. Everything checked out.&lt;/p&gt;

&lt;p&gt;You click it, and: connection failed. Before it ever reached authentication.&lt;/p&gt;

&lt;p&gt;My first guess was the key. It won't connect, so it must be access, right? Wrong, and that wrong guess is where the time goes.&lt;/p&gt;

&lt;p&gt;The button doesn't ask for a key, it drops in a placeholder. Cursor sends that placeholder, the server returns a perfectly correct 401, and instead of reporting a bad key, Cursor falls back to OAuth. It goes looking for OAuth settings at the standard spot, &lt;code&gt;/.well-known/oauth-authorization-server&lt;/code&gt;. Our server answered there with a custom response of its own — not the shape the spec describes. Cursor tried to parse it as OAuth metadata, found none of the fields it needed, printed "MCP OAuth provider initialized," and stopped there.&lt;/p&gt;

&lt;p&gt;Placeholder instead of a key, then a 401, then a fallback into OAuth, then a failure on our non-standard response. The key was never the problem. It's just the first thing everyone suspects.&lt;/p&gt;

&lt;p&gt;Our first fix was simple: type a real key in by hand. Cursor has a little pencil that opens &lt;code&gt;~/.cursor/mcp.json&lt;/code&gt;, you swap the placeholder for a real &lt;code&gt;sk_...&lt;/code&gt;, and it goes green. A valid key means the request never reaches OAuth discovery at all — the server just answers and hands over its tools. Fine. But "one click, now paste your key" is a click and a half, and not the button we came to build.&lt;/p&gt;

&lt;p&gt;Now, before anyone asks why we didn't do it properly from day one. A key instead of OAuth wasn't us cutting corners. It's where the whole ecosystem started: the MCP spec recommended API keys until March 2025 and only then made OAuth the baseline. The thing has been &lt;a href="https://www.descope.com/blog/post/mcp-auth-spec" rel="noopener noreferrer"&gt;rewritten three times in nine months&lt;/a&gt; — catching up to that is not an evening either. This same &lt;code&gt;.well-known&lt;/code&gt; is currently tripping up companies with rather more headcount than us: Atlassian has an &lt;a href="https://github.com/atlassian/atlassian-mcp-server/issues/205" rel="noopener noreferrer"&gt;open bug&lt;/a&gt; about OAuth-metadata validation, and Microsoft's own MCP clients &lt;a href="https://learn.microsoft.com/en-nz/answers/questions/5904511/mcp-client-ignores-authorization-servers-path-from" rel="noopener noreferrer"&gt;resolve the endpoint wrong&lt;/a&gt;. And when one study checked &lt;a href="https://www.obsidiansecurity.com/blog/when-mcp-meets-oauth-common-pitfalls-leading-to-one-click-account-takeover" rel="noopener noreferrer"&gt;660 OAuth servers, 27 of them&lt;/a&gt; — four percent — did full dynamic registration. Moving from a key to real OAuth isn't the slow kid's homework. It's the part most products just never finish.&lt;/p&gt;

&lt;p&gt;So instead of hiding the key, we fixed the place everything kept dying on. Cursor wants OAuth so badly? Give it OAuth. My engineers put proper, to-spec metadata at &lt;code&gt;/.well-known/oauth-authorization-server&lt;/code&gt; — issuer, authorize and token endpoints, client registration, PKCE, the whole set the editor kept hunting for. My contribution at this stage was reading closely and sitting next to them waiting for a miracle. It arrived.&lt;/p&gt;

&lt;p&gt;The chain runs straight now. Click the button, Cursor fetches the OAuth settings, finds real ones, pops the Publora login, you sign in, and it collects the token itself. Nothing typed by hand. The one click, as advertised.&lt;/p&gt;

&lt;p&gt;To reproduce it, it comes down to two things. A deeplink whose config is just &lt;code&gt;{ "url": "https://mcp.publora.com/mcp" }&lt;/code&gt; — the address, no key, login goes through OAuth. And a server that serves valid metadata at &lt;code&gt;/.well-known/oauth-authorization-server&lt;/code&gt; instead of something homemade. If your client prints "OAuth provider initialized" and then quietly dies, put your money on this: it read your &lt;code&gt;.well-known&lt;/code&gt; and didn't find what it wanted.&lt;/p&gt;

&lt;p&gt;The big catalogs are still ahead of us, moving at their pace and not ours. The Cursor install works today. So if you're in Cursor and you use Publora, poke the button and tell me how it went — genuinely curious, especially if it breaks somewhere new.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>oauth</category>
      <category>cursor</category>
      <category>webdev</category>
    </item>
    <item>
      <title>LinkedIn gave everyone a 'this looks like AI' button. I won't press it.</title>
      <dc:creator>Eugeniya Ivanova</dc:creator>
      <pubDate>Tue, 04 Aug 2026 11:29:33 +0000</pubDate>
      <link>https://dev.to/eugeniya_ivanova_4a58eadc/linkedin-gave-everyone-a-this-looks-like-ai-button-i-wont-press-it-114m</link>
      <guid>https://dev.to/eugeniya_ivanova_4a58eadc/linkedin-gave-everyone-a-this-looks-like-ai-button-i-wont-press-it-114m</guid>
      <description>&lt;p&gt;This week LinkedIn &lt;a href="https://techcrunch.com/2026/07/30/linkedin-adds-a-button-to-report-ai-generated-slop/" rel="noopener noreferrer"&gt;rolled out&lt;/a&gt; a "Seems like AI slop" button. Tap it under someone's post and the post disappears from your feed, while your report goes off to train their filter. I heard about it through work — keeping an eye on what platforms are up to is part of the job — but I read it less as a marketer and more as someone who writes, and who now, apparently, walks around under suspicion.&lt;/p&gt;

&lt;p&gt;LinkedIn's logic isn't hard to follow. According to the AI detector Pangram, they have &lt;a href="https://www.pangram.com/blog/ai-in-your-feed" rel="noopener noreferrer"&gt;the highest share of AI content&lt;/a&gt; of any platform: roughly a third of short posts and over forty percent of long ones. The feed really is drowning, and handing people a valve is a reasonable move.&lt;/p&gt;

&lt;p&gt;It isn't the valve that bothers me. It's whose hands it got put in.&lt;/p&gt;

&lt;p&gt;The "this is AI" verdict used to come from an algorithm, and even that missed plenty. Pangram itself claims a 0.01% false-positive rate — which sounds impressive until you multiply it by volume. One hundredth of a percent of millions of posts is still thousands of real people getting labelled as bots. And that's the machine, the one somebody at least tested. Now the "this is AI" stamp has been handed to every passerby, and the cost of getting it wrong is zero. Tap, post gone, on with your coffee. You'll never even find out you were wrong — there's no feedback loop pointing back at you.&lt;/p&gt;

&lt;p&gt;I haven't touched the button once, and I don't even like AI text — the smooth filler I spent &lt;a href="https://dev.to/eugeniya_ivanova_4a58eadc/i-trained-an-ai-to-sound-like-me-then-spent-three-rounds-undoing-it-3k96"&gt;a whole post&lt;/a&gt; pulling apart last time. But disliking slop and swatting a person are different reflexes with different results. Someone posts the best thing they know how to write, and someone else decides it "looks like a bot." That's one reader gone. That isn't fighting slop. That's slapping the hands of someone who's only just reaching for the keyboard.&lt;/p&gt;

&lt;p&gt;Here's the personal part. I write with AI and I don't hide it, but the text is mine: a pile of rules, defined styles, years of tuning, every paragraph edited by hand. Slow — though it used to be slower. AI isn't my author, it's a tool I don't let grab the wheel. I wrote a whole article about that.&lt;/p&gt;

&lt;p&gt;Then something happened that made it feel a lot more personal. A while back, in a work chat, I answered a former colleague — a plain message, a couple of paragraphs. She reads it and says: "Wow, you sound just like GPT — fast and structured." A compliment. The catch is that about two minutes passed between her question and my answer. There wasn't even enough time to open ChatGPT, write a prompt, wait for an answer, and paste it back. It was just a human who's spent a long time working with words. And it read as a machine.&lt;/p&gt;

&lt;p&gt;And for a couple of days I was pleased about it — imagine, mistaken for GPT, fast and clean. Now it turns out that's exactly the thing I'm supposed to worry about. Same sentence. Same moment. A week later it had gone from a compliment to evidence, while nothing about me had changed. What changed is that there's now a button for it.&lt;/p&gt;

&lt;p&gt;Fluency is being read as forgery. Fast and clean means you didn't write it yourself, and there's now a button wired to that assumption.&lt;/p&gt;

&lt;p&gt;The irony is that AI was trained on well-edited human prose — which is exactly why it comes out so smooth and combed. And that smoothness is now how we try to spot it: we hunt for the fingerprints of a good editor, and we reliably find them on people who simply know how to write. Congratulations to all of us.&lt;/p&gt;

&lt;p&gt;My own way out of this is as old as it gets: divide and conquer. The machine is fast and a little too good — and sometimes not good at all, and that's the line I can still see and it can't. I handed it the posting across networks; works great — it's mechanical anyway. But writing is different. No matter how much I train it, it keeps sliding back into "we did it this way before, so this must be right." Except the new piece in front of it doesn't fit the old rules, and it doesn't notice that — I do. So here's the split: authorship and judgment stay with the human, logistics go to the machine. That's how I run it through &lt;a href="https://publora.com" rel="noopener noreferrer"&gt;Publora&lt;/a&gt; (I'm on the team): I write by hand, and the delivery across platforms goes to an agent. No AI writes my posts — it's on backup duty, in the corner, not at the wheel.&lt;/p&gt;

&lt;p&gt;How this ends, honestly, I don't know. For now I've stocked up on popcorn and I'm watching from two angles. Angle one: people start writing worse on purpose — breaking the rhythm, sprinkling in typos, watering it down, anything to avoid looking suspiciously smooth. A platform that penalizes quality, and a crowd cheerfully dumbing itself down to a C. Angle two: some just stop posting, because the fun runs thin when any passerby gets to weigh your fluency for "real or not." Which one wins, I won't guess. Popcorn's ready.&lt;/p&gt;

&lt;p&gt;I still won't press the button. Slop exists, no argument, plenty of it. But pressing something in two seconds, with zero accountability, that sorts people into "human / not human" — that I'm not up for.&lt;/p&gt;

&lt;p&gt;By the way, a huge secret: I wrote this article myself, by hand. The formatting and posting I handed to an agent — the writing was mine, the publishing wasn't, and that's exactly how I want it.&lt;/p&gt;

&lt;p&gt;Would you press it? And have you ever caught yourself deciding someone's fast, clean writing was too smooth to be real?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>writing</category>
      <category>career</category>
      <category>discuss</category>
    </item>
    <item>
      <title>You're writing for two readers, and one of them is a model</title>
      <dc:creator>Eugeniya Ivanova</dc:creator>
      <pubDate>Thu, 30 Jul 2026 11:28:17 +0000</pubDate>
      <link>https://dev.to/eugeniya_ivanova_4a58eadc/youre-writing-for-two-readers-and-one-of-them-is-a-model-4fp4</link>
      <guid>https://dev.to/eugeniya_ivanova_4a58eadc/youre-writing-for-two-readers-and-one-of-them-is-a-model-4fp4</guid>
      <description>&lt;p&gt;Every post you write now goes to two readers.&lt;/p&gt;

&lt;p&gt;The first is a person. They open it, read it, decide whether it was worth their time. Nothing new there — we've been writing for that reader forever.&lt;/p&gt;

&lt;p&gt;The second reader showed up recently, and it isn't a person. It's a model. It reads your text, works out what it's actually about, and decides whether to surface it or quote it in an answer. You're writing for both now. They want slightly different things — but less different than you'd fear.&lt;/p&gt;

&lt;p&gt;LinkedIn is the easiest example because they published how it works. Their &lt;a href="https://www.linkedin.com/blog/engineering/feed/engineering-the-next-generation-of-linkedins-feed" rel="noopener noreferrer"&gt;engineering post&lt;/a&gt; describes the new way content gets picked: instead of the old pile of separate pipelines, there's now an LLM retriever that turns every post and every profile into a vector of meaning, then pulls a couple thousand candidates out of millions by how close those meanings sit. That's the first point where a model reads your text. Before anything gets ranked, something has already worked out what you wrote about.&lt;/p&gt;

&lt;p&gt;Here's the part almost every write-up got wrong. The retrieval runs on an LLM, yes. But the final ranking of those candidates isn't done by a language model — it's a separate transformer called Feed-SR. In &lt;a href="https://arxiv.org/abs/2602.12354" rel="noopener noreferrer"&gt;their own paper&lt;/a&gt;, LinkedIn says plainly that they tried an LLM ranker and chose not to ship it: the transformer scored better and cost less to run. So "the feed is one giant LLM now" isn't what happened. The LLM sits exactly where it's decided whether your text gets considered at all — at the reading-for-meaning step.&lt;/p&gt;

&lt;p&gt;There's one catch worth mentioning. Online, everyone calls this system "360Brew" and recites its exact rules. 360Brew is a pre-production research model from a &lt;a href="https://arxiv.org/abs/2501.16450" rel="noopener noreferrer"&gt;separate paper&lt;/a&gt; whose every version on arXiv is marked withdrawn, and LinkedIn never confirmed it runs the feed. Their own &lt;a href="https://arxiv.org/abs/2602.12354" rel="noopener noreferrer"&gt;Feed-SR paper&lt;/a&gt; contradicts the story directly. Somewhere along the way, someone attached a catchy name to a real engineering paper, and everyone else repeated it. If a source can't get the name of the engine right, treat its "exact rules" as a guess too. Read the primary source, not the recap of a recap.&lt;/p&gt;

&lt;p&gt;The name isn't the important part. The shift is. The feed used to be gameable with mechanics — keywords, early-hour posting windows, pods trading likes. Now there's a model at the door that reads for meaning, and you can't fool it with the tricks that fooled a counter. It reads roughly the way an assistant reads a question you asked it.&lt;/p&gt;

&lt;p&gt;That's where the two readers start looking surprisingly similar. The assistant looking for the answer to a professional question and the feed retriever deciding whether to surface your post are doing the same job: reading the text for whether there's a specific point about a specific thing. What lands with the first lands with the second. Neither gets much from smooth, empty writing; both reach for text where it's clear what it's about and who it's for.&lt;/p&gt;

&lt;p&gt;This doesn't mean you should start writing for the algorithm. If anything, it points in the opposite direction. Writing for the machine used to mean writing worse: stuff in keywords, pad to length, staple on five hashtags. Now the machine reads like a reader, so writing for it means writing more clearly. Don't try to please the algorithm. Try to be understood. One concrete point beats ten blurry ones. An example someone can quote beats a paragraph of generalities. A topic you can be pinned to beats trying to appeal to everyone.&lt;/p&gt;

&lt;p&gt;There is one trap, though. Optimizing to "get quoted by a model" is a slippery road, and at the end of it waits exactly the AI slop I spent last time learning to scrub out — the confident, weightless prose that says nothing. The irony is that the model at the door doesn't like it either; it tells specific from smoothly-empty about as well as a person does. Push that too far and you end up writing for neither. The only thing that seems to work for both is having something specific to say.&lt;/p&gt;

&lt;p&gt;LinkedIn just happened to show how the system works first. Everything that retrieves and quotes you — search, assistants, feeds — is moving the same way, from "how many signals did you stack" to "what is this actually about." What people buried as SEO back in 2016 didn't die. Search just stopped counting and started reading.&lt;/p&gt;

&lt;p&gt;So yes, you've got two readers now. But write honestly and to the point, and the second one isn't extra work. It wants the same thing the first one does.&lt;/p&gt;

&lt;p&gt;Have you caught yourself writing with one eye on how a model will read it? Does the text come out better or worse for it?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>writing</category>
      <category>seo</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I tried to build an Apple Shortcut from code. Apple said no, four times.</title>
      <dc:creator>Eugeniya Ivanova</dc:creator>
      <pubDate>Mon, 27 Jul 2026 10:32:16 +0000</pubDate>
      <link>https://dev.to/eugeniya_ivanova_4a58eadc/i-tried-to-build-an-apple-shortcut-from-code-apple-said-no-four-times-4l5d</link>
      <guid>https://dev.to/eugeniya_ivanova_4a58eadc/i-tried-to-build-an-apple-shortcut-from-code-apple-said-no-four-times-4l5d</guid>
      <description>&lt;p&gt;We have a REST API. Apple has Shortcuts, an automation app that can send HTTP requests. Wiring one to the other looked like a free win: "Hey Siri, publish this" → POST to our API → done. No backend, no OAuth, no app to install.&lt;/p&gt;

&lt;p&gt;It works. It's on my phone. What I couldn't do — and this is where it got interesting — was build, edit, version, and ship that shortcut from code. Here are the walls I hit, with the exact errors, so you can decide whether to spend an evening the way I spent mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What works
&lt;/h2&gt;

&lt;p&gt;The shortcut itself is trivial, three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Ask for Input (text) → "What are we posting?"&lt;/li&gt;
&lt;li&gt;Get Contents of URL → POST to &lt;code&gt;https://api.publora.com/api/v1/create-post&lt;/code&gt;, header &lt;code&gt;x-publora-key: &amp;lt;key&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Request body (JSON): &lt;code&gt;content&lt;/code&gt; from step 1, &lt;code&gt;platforms&lt;/code&gt; as an array with one connection ID&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Run it, Siri asks, you dictate, the post goes out. Here's the curl equivalent, to show how little logic there is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.publora.com/api/v1/create-post &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"x-publora-key: YOUR_API_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"content":"Shipped from Siri","platforms":["linkedin-ABC123"]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leave out &lt;code&gt;scheduledTime&lt;/code&gt; and the post is created as a draft, which is what you want while testing: nothing goes to a live audience.&lt;/p&gt;

&lt;p&gt;Building it on the phone took about forty minutes, most of that spent hunting for the right actions in a localized UI. Building it from code took forever. Here's why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall 1: you can't hand-write a &lt;code&gt;.shortcut&lt;/code&gt; file
&lt;/h2&gt;

&lt;p&gt;A shortcut on disk is a property list: &lt;code&gt;WFWorkflowActions&lt;/code&gt; is an array of dictionaries, each with an action identifier and its parameters. That part is easy to write by hand.&lt;/p&gt;

&lt;p&gt;The file has to be signed before anything will open it, and macOS ships a CLI for that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;shortcuts sign &lt;span class="nt"&gt;--mode&lt;/span&gt; anyone &lt;span class="nt"&gt;--input&lt;/span&gt; mine.plist &lt;span class="nt"&gt;--output&lt;/span&gt; mine.shortcut
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It rejects hand-written plists. XML, binary, a minimal one-action file, a full set of top-level keys, both signing modes — every time, the same "isn't in the correct format." &lt;code&gt;sign&lt;/code&gt; appears to accept only files the app itself produced.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall 2: &lt;code&gt;sign&lt;/code&gt; doesn't work on a real file either
&lt;/h2&gt;

&lt;p&gt;Fine — I got a real one (trick below) and tried again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;shortcuts sign --mode anyone --input real.shortcut --output out.shortcut
Error: In order to do this, you must be signed into iCloud.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I am signed into iCloud. iCloud Drive is on, the &lt;code&gt;~/Library/Mobile Documents/com~apple~CloudDocs&lt;/code&gt; folder is there, &lt;code&gt;defaults read MobileMeAccounts&lt;/code&gt; shows the account. I tried it as a normal user process, through &lt;code&gt;launchctl asuser&lt;/code&gt;, and in a real Terminal.app GUI session. Same error each time. On macOS 14.6, &lt;code&gt;shortcuts sign&lt;/code&gt; seems to be simply broken. It may behave differently on other versions, but on 14.6 it's dead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall 3: the import URL scheme only trusts iCloud
&lt;/h2&gt;

&lt;p&gt;There's a URL scheme to import a shortcut from a link:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;shortcuts://import-shortcut?url=&amp;lt;url&amp;gt;&amp;amp;name=Publora
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Serve the file over local HTTP → "Import failed: the specified shortcut URL is invalid." Serve it over real HTTPS (I tried a gist) → same error. The scheme seems to accept only &lt;code&gt;icloud.com/shortcuts/...&lt;/code&gt; links, i.e. files Apple itself signed. There's no side door.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall 4: you can't even read what's already installed
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;shortcuts list&lt;/code&gt; happily prints my shortcuts, so the data exists somewhere. But the group containers are empty shells, and the only SQLite files in them are four-kilobyte stubs from a migration folder. Nothing to read, nothing to use as a template.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one thing that worked: pulling a shortcut out of an iCloud link
&lt;/h2&gt;

&lt;p&gt;When someone shares a shortcut with you, the link is backed by an undocumented endpoint that returns the actual file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://www.icloud.com/shortcuts/api/records/&amp;lt;share-id&amp;gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; rec.json
&lt;span class="c"&gt;# fields.name.value           → shortcut name&lt;/span&gt;
&lt;span class="c"&gt;# fields.shortcut.value       → unsigned plist (readable, editable)&lt;/span&gt;
&lt;span class="c"&gt;# fields.signedShortcut.value → Apple-signed blob&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One catch: the &lt;code&gt;downloadURL&lt;/code&gt; contains a literal &lt;code&gt;${f}&lt;/code&gt; placeholder you have to substitute before the download resolves.&lt;/p&gt;

&lt;p&gt;Note: the values in the snippets below are anonymized — the IDs, keys, and parts of the endpoint response are not real. The point is that this path exists and that things travel out with the link, not a working recipe against other people's shortcuts. Exact behavior depends on the specific shortcut, so there's nothing to reproduce byte for byte here.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;python3&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plistlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;
&lt;span class="n"&gt;rec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rec.json&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;fields&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;shortcut&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;downloadURL&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;${f}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;file&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;raw.shortcut&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;wb&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plistlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;raw.shortcut&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;WFWorkflowActions&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;EOF&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the shortcut can be read, diffed, and rewritten programmatically. You just can't push it back. This road is read-only.&lt;/p&gt;

&lt;h2&gt;
  
  
  The security part nobody warns you about
&lt;/h2&gt;

&lt;p&gt;Look at that field list again. Everything baked into a shortcut travels with the link, and the link is public to anyone who has it. In my first working version, the API key was written straight into the header field. So for a while, my key was one URL away from anyone I sent the shortcut to.&lt;/p&gt;

&lt;p&gt;That's the real lesson, and it isn't about reading other people's shortcuts — it's about what you hand out without noticing. If you're going to share a shortcut that talks to an authenticated API:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never bake in a credential. Ask for it at runtime, or use Apple's import questions so the installer fills it in themselves.&lt;/li&gt;
&lt;li&gt;Don't make the user hunt for internal IDs by hand. Ours calls &lt;code&gt;GET /api/v1/platform-connections&lt;/code&gt; and reads the &lt;code&gt;platformId&lt;/code&gt; itself.&lt;/li&gt;
&lt;li&gt;When you're done testing, delete the iCloud link (Share → Remove iCloud Link) and rotate anything that may have leaked.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this means if you were hoping to ship one
&lt;/h2&gt;

&lt;p&gt;No build step. Shortcuts are GUI artifacts. No CI, no review, no &lt;code&gt;git diff&lt;/code&gt; on a change.&lt;/p&gt;

&lt;p&gt;No programmatic distribution. The installable artifact is an iCloud link, and only the app can produce it.&lt;/p&gt;

&lt;p&gt;No store. Apple curates its own gallery inside the app, with no submission form. The real shelf is community catalogs like RoutineHub.&lt;/p&gt;

&lt;p&gt;A per-user key is a hard ceiling. Anyone unwilling to paste a key won't install your shortcut, however nice the flow is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we shipped instead
&lt;/h2&gt;

&lt;p&gt;The thing I actually wanted — tell an assistant, and the post goes out — turned out not to need Apple at all. Our MCP server (&lt;code&gt;mcp.publora.com&lt;/code&gt;) does it in Claude and Cursor today, with no install and no key typed into a text field on a phone screen. And even the auth header is different there — MCP wants &lt;code&gt;Authorization: Bearer&lt;/code&gt;, not the REST &lt;code&gt;x-publora-key&lt;/code&gt;. No walls on that path.&lt;/p&gt;

&lt;p&gt;I don't regret the evening. I now know exactly where the wall is, and I can read any shortcut someone sends me. Not enough to ship one. Enough to post.&lt;/p&gt;

&lt;p&gt;Have you hit a wall trying to automate Shortcuts for real? Which one?&lt;/p&gt;

</description>
      <category>apple</category>
      <category>automation</category>
      <category>api</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Chrome Rejected Us Twice. Firefox Approved Instantly. Edge Is Still Thinking</title>
      <dc:creator>Eugeniya Ivanova</dc:creator>
      <pubDate>Wed, 22 Jul 2026 09:26:07 +0000</pubDate>
      <link>https://dev.to/eugeniya_ivanova_4a58eadc/chrome-rejected-us-twice-firefox-approved-instantly-edge-is-still-thinking-e0e</link>
      <guid>https://dev.to/eugeniya_ivanova_4a58eadc/chrome-rejected-us-twice-firefox-approved-instantly-edge-is-still-thinking-e0e</guid>
      <description>&lt;p&gt;You have a few extensions installed right now, and you probably can't name them all. An ad blocker, a password manager, something you installed three years ago for one specific task that has been quietly living there ever since. That's normal: the average Chrome user has eight to twelve installed and actively uses two or three.&lt;/p&gt;

&lt;p&gt;The whole point of an extension is to remove a context switch. Instead of opening a new tab, finding the service, logging in, and pasting the link, you get one icon right where you already are. The value is in skipping those five little steps, which is why extensions can look trivial until you start using one.&lt;/p&gt;

&lt;p&gt;That was my situation. I write articles and then share them across social accounts: finish the piece, open our product in a new tab, copy the link, pick the accounts, send. Five context switches. Enough that I'd often leave it for later.&lt;/p&gt;

&lt;p&gt;I wanted one button, so we built an extension. Click the icon and it pulls in the URL of the page you're on. You write your post and send it to the accounts you've picked without leaving the tab. The whole thing weighs seventeen kilobytes.&lt;/p&gt;

&lt;p&gt;For scale: the Chrome Web Store held 283,585 extensions as of July 18, 2026, against 30,509 in Edge in mid-June and roughly 83,000 in Firefox earlier this year. Chrome is nearly ten times the size of Edge, which matters later. Meanwhile, extension trackers put the median at about eighteen users, with seventy percent sitting at a hundred or fewer. Everyone sees the giants, but a typical extension is used by a few dozen people.&lt;/p&gt;

&lt;p&gt;Launching wasn't the interesting part. Over about two weeks, we went through three stores and collected two rejections and one HTTP 400 that locked out half the team. That's what this article is about, along with the actual wording we got.&lt;/p&gt;

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

&lt;p&gt;Manifest V3, a popup, and a small amount of logic. The extension reads a token from the logged-in tab of our product, exchanges it for an API key, and stores it in &lt;code&gt;chrome.storage&lt;/code&gt;, so nobody has to copy anything by hand.&lt;/p&gt;

&lt;p&gt;Here's the manifest we ended up with:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;{&lt;br&gt;
"manifest_version": 3,&lt;br&gt;
"name": "...",&lt;br&gt;
"version": "0.2.3",&lt;br&gt;
"permissions": ["storage"],&lt;br&gt;
"action": { "default_popup": "popup.html" }&lt;br&gt;
}&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;More interesting is what's missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chrome turned us down twice
&lt;/h2&gt;

&lt;p&gt;The first rejection came back as &lt;strong&gt;Spam and Placement / excessive keywords&lt;/strong&gt;. In the description, we had listed all ten platforms the extension works with — LinkedIn, X, Instagram, and the rest — so readers would know where they could post. The automated filter read it differently.&lt;/p&gt;

&lt;p&gt;Chrome's policy defines keyword spam as irrelevant or excessive keywords in a description used to manipulate ranking. On paper, we fit the definition: ten recognizable brand names in a row inside a short description look like an attempt to influence search, regardless of intent.&lt;/p&gt;

&lt;p&gt;When your product genuinely supports ten networks, an honest list of them can look exactly like keyword spam. We fixed it by generalizing: "all your connected social accounts" instead of the full list, with no brand names at all.&lt;/p&gt;

&lt;p&gt;The second rejection arrived as &lt;strong&gt;Use of Permissions&lt;/strong&gt;. We had requested &lt;code&gt;tabs&lt;/code&gt; but only ever called &lt;code&gt;chrome.tabs.create&lt;/code&gt;, which doesn't require the &lt;code&gt;tabs&lt;/code&gt; permission and works fine without it. It was a textbook case: permission requested, permission unused.&lt;/p&gt;

&lt;p&gt;The policy requires the narrowest permissions necessary for your features, and the automated checks are good at spotting a gap between the manifest and the code. We dropped &lt;code&gt;tabs&lt;/code&gt; and kept &lt;code&gt;storage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;An iteration later, we added an "insert this page" feature, and that one needed &lt;code&gt;activeTab&lt;/code&gt; — the narrow version of the permission we'd been rejected for. It's granted when the user clicks, applies only to the active tab, and the store has no problem with it. The lesson was simple: your manifest should contain exactly what your code actually calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Firefox: declare your data, get approved immediately
&lt;/h2&gt;

&lt;p&gt;There was no rejection here, just a validator error for a missing property. Since November 3, 2025, every new extension has had to declare its data collection in the manifest through &lt;code&gt;browser_specific_settings.gecko.data_collection_permissions&lt;/code&gt;. If you collect nothing, you still have to say so explicitly with &lt;code&gt;none&lt;/code&gt;. Without that declaration, the build doesn't get signed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;"browser_specific_settings": {&lt;br&gt;
"gecko": {&lt;br&gt;
"data_collection_permissions": {&lt;br&gt;
"required": ["authenticationInfo"]&lt;br&gt;
}&lt;br&gt;
}&lt;br&gt;
}&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;After that came automated screening and almost immediate approval. Firefox was easily the fastest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge: still waiting
&lt;/h2&gt;

&lt;p&gt;Technically, it's the same Chromium and the same extension, but the differences start before you get anywhere near the code. The developer program won't accept a work or school account, so we had to register a personal Microsoft account in an incognito window to stop the browser from helpfully supplying the corporate one.&lt;/p&gt;

&lt;p&gt;Then the validator said "manifest missing." The problem turned out to be the archive: &lt;code&gt;manifest.json&lt;/code&gt; has to sit at the root of the ZIP rather than inside a nested folder. We repacked it flat with &lt;code&gt;zip -j&lt;/code&gt;, and it went through.&lt;/p&gt;

&lt;p&gt;Then we waited.&lt;/p&gt;

&lt;p&gt;We got published, uploaded an update, and now we're waiting again. At least the current version stays available while the update is under review. Users keep running what's already live.&lt;/p&gt;

&lt;h2&gt;
  
  
  The HTTP 400 that only worked for me
&lt;/h2&gt;

&lt;p&gt;The team went to test the extension, and every one of them hit HTTP 400 on auto-connect. It worked fine for me.&lt;/p&gt;

&lt;p&gt;First, we ruled out authentication. A broken or empty token returns 401, and we were getting 400. The token was being accepted; something else in the request was being rejected.&lt;/p&gt;

&lt;p&gt;Then came a false lead. While digging through the web app bundle, we found that it sends both an access token and a refresh token, while the extension was only sending the first. It looked like the answer. We fixed it. It didn't help.&lt;/p&gt;

&lt;p&gt;Then I realized why it only worked for me. The code had a guard along the lines of &lt;code&gt;if (apiKey) return&lt;/code&gt;. My key was already sitting in the cache, so the mint request never fired. I hadn't touched the branch that was failing for everyone else in months.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;{ "error": "Maximum of 10 active API keys allowed" }&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The extension minted a fresh key on every connect. Over a week of testing, people had accumulated ten keys and hit the ceiling.&lt;/p&gt;

&lt;p&gt;That cost us most of a day and taught us two things. Never diagnose a 400 without reading the response body — the status code tells you the request was rejected, and only the server knows why, but it will usually tell you if you ask. And "works on my machine" can simply mean you have a cached result while the broken path only runs on a clean install.&lt;/p&gt;

&lt;p&gt;A few other things came up along the way. Store review doesn't test your integration: the reviewer has no account in your product, so functional bugs sail through, because what's being checked is policy, permissions, and the listing — a green status only means you didn't break the store's rules. Screenshots have to show your real interface, since invented marketing graphics get spotted. Trader verification is its own saga, with Organization type, a D-U-N-S number, and a name and address that have to match the registry character for character or you get "no match." And Edge has its own asset dimensions and its own promotional tile, which can't include Chrome branding.&lt;/p&gt;

&lt;h2&gt;
  
  
  So was it worth it?
&lt;/h2&gt;

&lt;p&gt;The extension is tiny, but it solves a problem I have every day, which is probably the best reason to build anything. If your product lives in a browser, an extension like this takes about a week to put together. Most of that time goes into working out what each store wants, and all three want different things.&lt;/p&gt;

&lt;p&gt;Our extension is two days old. Now we'll see whether anyone else finds it as useful as we do.&lt;/p&gt;

&lt;p&gt;We're digging further now, toward an add-on that would let you post straight from the document or deck you're working in. That's a different marketplace with different rules, so I suspect there'll be a part two.&lt;/p&gt;

&lt;p&gt;Here's the link if you want to try it: &lt;a href="https://chromewebstore.google.com/detail/publora-%E2%80%94-schedule-social/pkneoakicplhpdlhchgobccmeelhboai" rel="noopener noreferrer"&gt;Publora — Schedule Social Posts&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Have you shipped anything to the extension stores? What did they reject it for?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>discuss</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
