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.
The plugin itself is simple: one main.js
, 557 lines, zero dependencies, Obsidian API only. It takes the note you have open and sends it to your social accounts on a schedule.
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.
Before a plugin lands in the catalog, it gets reviewed against the Plugin guidelines. I read them beforehand and still came back with notes. Some requirements are much easier to notice once a reviewer points directly at them.
These were my six:
innerHTML
.createEl
. They check for this because innerHTML
plus third-party text is an open door.styles.css
,titleEl
,Publora: Publora: Send note
. It stutters.requestUrl
fetch
runs into problems on mobile, so if the plugin is isDesktopOnly: false
, use Obsidian's API.None of this changed what the plugin did. But doing it upfront would have saved me a review round and a couple of days.
Submission goes through community.obsidian.md: sign in with your Obsidian account, then connect GitHub separately. The happy path is documented. The potholes aren't.
Mine:
"You do not own this repository" — when you clearly have access. If the repo belongs to an organization, choose the organization as the submission owner instead of Myself
. The error disappeared immediately.
Your GitHub organization membership has to be public. The portal only sees public members. None of ours were public, so as far as the portal was concerned, our organization contained approximately nobody.
One request fixes your membership:
PUT /orgs/{org}/public_members/{username}
The catch: you can only make your own membership public this way. Each person has to do it with their own token.
The rate limiter. After several attempts, the portal starts replying with Please wait before trying again
. It doesn't say how long, and clicking again only makes things worse.
The solution turned out to be extremely technical: leave it alone and come back later. Then click once.
The review passed, but there was one note left: the release files had no artifact attestation.
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 main.js
attached to a GitHub Release.
Those two files can be different.
Artifact attestation lets someone verify where the release file came from. GitHub Actions, through Sigstore, ties the artifact to a specific commit and workflow. This is the release.yml
I ended up with — it also checks the tag matches the manifest version before it publishes:
name: Release
on:
push:
tags:
- '*'
permissions:
contents: write
id-token: write
attestations: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check the tag matches the manifest
run: |
tag="${GITHUB_REF_NAME}"
manifest="$(node -p "require('./manifest.json').version")"
if [ "$tag" != "$manifest" ]; then
echo "Tag $tag does not match manifest version $manifest"
exit 1
fi
- name: Attest the release assets
uses: actions/attest-build-provenance@v2
with:
subject-path: |
main.js
manifest.json
styles.css
- name: Publish the release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${GITHUB_REF_NAME}" \
main.js manifest.json styles.css \
--title "${GITHUB_REF_NAME}" \
--generate-notes
The part that cost me the most time was these two permissions:
id-token: write
attestations: write
Without them, my workflow went green but no attestation appeared.
Everything looked fine. Everything was not fine.
Once I added the permissions, the check actually passed and verified GitHub artifact attestation
showed up in the review.
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.
At submission time, I still had one thing I hadn't managed to verify.
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.
Would the API accept the token from this new OAuth flow?
Probably.
Had I actually tested it?
No.
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.
Instead, I built a fallback. The plugin sends the credential — whether that's the signed-in token or a pasted key — in the same x-publora-key
header. If the request comes back 401
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:
async function callApi(settings, method, path, body, plugin) {
const credential = plugin ? await plugin.credential() : settings.apiKey;
if (!credential) {
throw new Error('Not connected yet. Open Settings, then Publora, and press Connect.');
}
const usedToken = Boolean(
plugin && plugin.settings.oauth &&
credential === plugin.settings.oauth.accessToken
);
const response = await requestUrl({
url: BASE_URL + path,
method,
headers: { 'x-publora-key': credential, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
throw: false,
});
if (response.status === 401) {
// The signed-in token was refused. If a key is also configured, use it
// rather than stranding the user mid-post.
if (usedToken && settings.apiKey) {
return callApi(Object.assign({}, settings, { oauth: null }), method, path, body, null);
}
throw new Error(
usedToken
? 'Publora refused the signed-in account. Reconnect in Settings → Publora, or paste an API key there under Advanced.'
: 'Publora rejected the API key. Check it in Settings → Publora.',
);
}
return response.json;
}
The retry is just the same function calling itself with oauth
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.
But I didn't know that when I submitted the plugin.
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.
As of August 19: 33 installs.
Not a lot.
But the version distribution shows most of those installs on the latest version, so people are updating rather than installing once and disappearing.
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.
So right now I'll take the 33.
At least I know what they mean.
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.
The typing is increasingly the easy part.
The annoying part is still noticing the thing you haven't tested.
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?