Over the last while I've built integrations for three places where people work with text and images: Obsidian, VS Code, and Figma. Doing a few of them back to back, I noticed something you don't see from a single one.
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.
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.
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.
Figma gives you bytes, not a file
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×.
const bytes = await nodes[0].exportAsync({
format: "PNG",
constraint: { type: "SCALE", value: 2 },
});
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.
Then the first quirk of the foreign house. Figma hands the plugin not a file but raw PNG bytes — exportAsync() returns a Uint8Array. 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.
So "publish one frame with a caption" turned into this:
create post → get upload URL → PUT PNG → complete media → update post
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.
Two worlds inside one plugin
A Figma plugin isn't one JavaScript. The main thread sees the document — that's where we read the selection and call exportAsync(). Network calls live in the UI, a separate context. Between them, a wall.
So the exported Uint8Array 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 postMessage, reassemble the bytes on the other side.
// main thread
const bytes = await exportSelection();
figma.ui.postMessage({
type: "exported",
bytes: Array.from(bytes),
});
// UI
exportedBytes = new Uint8Array(msg.bytes);
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."
The next detail didn't let go so easily. The plugin UI runs in a sandboxed iframe with Origin: null. Our API, naturally, wasn't set up to accept browser requests from null — 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.
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 figma.clientStorage and just gets passed through to Publora when the plugin makes a request.
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.
A composer stops being simple very fast
Once the requests finally worked, the boring per-platform rules came back.
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.
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.
function captionLimit() {
const limits = [...selected]
.map((id) => CAPTION_LIMITS[platformOf(id)])
.filter((limit) => typeof limit === "number");
return limits.length ? Math.min(...limits) : null;
}
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.
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.
Nothing impressive in that code. It just took more time than getting exportAsync() to work.
And then you have to submit it
The other half of "make a Figma plugin" is making something Figma will actually list.
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.
The manifest has to be specific about network access. 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."
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.
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, postMessage, the UI, the worker, the API, storage. That's the one I watched hardest.
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.
What it adds up to
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."
And it isn't only Figma. Obsidian made me go through its own requestUrl for network calls, because plain fetch hits the engine's limits on mobile. VS Code has its own ways. Figma has two contexts and Origin: null. 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.
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.
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.
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?
Top comments (0)