Obsidian Kirby Sync
After more than ten years with Kirby as a CMS for blogging, I finally have a workflow that I really like.

Kirby is a great CMS to run a blog. Data is stored in text files and texts can be written in Markdown. The panel - Kirby's admin interface - can be customized down to the last detail, and each page can have its own set of fields.
That makes it possible to customize your own blog to your needs and design things to make blogging especially easy.
But as much as I like the surface and have built cool features and plugins for it, there was a big catch with the whole set-up all these years: I don't write my texts directly in the CMS, but in a Markdown editor.
That was once VS Code, then iA Writer, then Ulysses, back to iA Writer and in between often Obsidian. They all had a problem in common: As soon as a text was in Kirby, the Kirby version and the version in the editor diverged.
Who doesn't experience that: A text is online and when you look directly at the page, you still notice an error. A link is not set correctly or there's a typo somewhere. So I correct this error quickly in the panel so that the change is immediately online.
At first, when the motivation was still high, I also corrected these errors in my document in the Markdown editor. But to be honest, at some point I didn't feel like it anymore, pushed it back and eventually stopped altogether.
So the texts on the website are in a different form than they are in the editor.
That wouldn't be a problem if I didn't also like to have the texts in the editor, which is also a kind of database for me.
I am a big fan of the Zettelkasten.
I also think it's a good idea to always have texts as files locally instead of just online.
The first problem is therefore the synchronicity between the two worlds.
The second problem is the state of both documents.
In addition to the text, there are numerous metadata such as tags, a date and the actual status, i.e. whether a document is a draft, unlisted or published. Some editors have solutions for this.
The Markdown editors
I used VS Code to write Markdown at first, but I switched away from it eventually. VS Code is a code editor, after all, and many features that I expect from a text editor are missing, perhaps they could be retrofitted, but conversely, they actually have nothing to do with a code editor either. So I quickly switched back to other apps.
iA Writer
iA Writer is one of the best editors out there. It's minimally designed and still offers all the features you could want when writing. Besides the pretty (and minimal) interface, I especially liked the grammar helpers. I've been using iA Writer since the first version and this feature was one of my most used. I can have different word types displayed, and that way quickly find filler words and unnecessary adjectives.
Here you see iA Writer's style check:

And that's how colorful it can look when you highlight all parts of the text:

Everything can be switched on and off in detail.

But especially great is the possibility of publishing texts via different services/APIs directly from iA Writer:

As you can see, the most popular interfaces are represented here. Particularly interesting is certainly Micropub because the standard is not tailored to a specific CMS. For my case, this was only limited useful, though.
Sebastian has written a great plugin for it, which also allowed me to publish short posts as notes via my smartphone – and my texts from iA Writer.
The plugin is unfortunately no longer being developed. Although it still worked with the latest Kirby 4 versions, it became too risky for me; an unmaintained interface to my own CMS is not a good idea in the long term.
There is also a fundamental problem here: communication only goes in one direction, from the editor to the blog, but not back. It's only meant for publishing.
When it comes to managing my texts, I have never really warmed up to iA Writer unfortunately. The file/folder view never felt clear enough to me, especially when it comes to documents being connected and sorted into different "categories".
Ulysses
Ulysses is a bit better positioned here. Besides folders, you can also create projects, so different content can be grouped well. That helps me a lot because I don't just write texts for my own blog and can create a separate project for each blog.
Ulysses also comes with its own style and grammar check, which works very well; although not as conspicuous as in iA Writer. The recommendations here are often more sophisticated and extensive, usually several suggestions are made on how to formulate something better more elegantly.

Obviously, the surface is also quite minimalistic, although not as minimal as in iA Writer. I generally find iA Writer to be a bit "rounder," especially like the iA Writer Font.
In Ulysses, you can also publish texts directly. As can be seen, all common channels are represented here as well. Unfortunately, there is no Micropub, but instead a direct interface to Micro.blog. A feature request from a few years ago has unfortunately not been able to move anything here so far.

Here the problem of the missing feedback channel remains. Texts can be published but not retrieved back into the editor.
Obsidian
The all-in-one-show.
Obsidian is open source and free. Accordingly, it is widely used. There are numerous themes and at least as many plugins to extend the editor.
Some users' favorite pastime is to tune Obsidian with plugins and self-built constructions so that it works like a project management system; then make endless video series on YouTube and finally complain about how complicated Obsidian is and explain that they are now switching to Notion.
Don't panic! Obsidian can be like that, but you have to make an effort. After installation, Obsidian is first and foremost a text editor with a few basic, helpful functions.
I still opted for a theme because I absolutely wanted something minimal. I use Obsidian Boom with some additional settings so that everything looks clean:

Because I like the font from iA Writer so much, I downloaded and installed it. It is freely available but limited in its usage.
Out of the box, Obsidian doesn't have any special style or grammar features. This can be achieved with a plugin. It then works similarly well to Ulysses:

Here I am using the LanguageTool Integration Plug-in. Unfortunately, it is no longer actively developed, I hope it will remain with us for a while longer, or that someone will find it to resume development.
I also have one of the Focus plugins in use, which dims all UI elements and thus creates a similarly minimal representation as iA Writer and Ulysses.

There are some plugins for syncing text and settings. Most of them are more like backup syncs or for synchronizing between different computers and mobile devices. Here I decided to use Obsidian Sync a while ago. It seems to run the most stable to me, and this way I can support the developers. The version history already saved my butt once when I was experimenting with a plugin of my own; so it was worth the purchase.
That brings us to the main topic: publishing texts. There are also some plugins here, but nothing that would work with Kirby. However, I was happily surprised to find out that Obsidian plugins are written in TypeScript, something I earn my money with and therefore know well.
I couldn't resist any longer and started writing my own plugin.
Connect Obsidian and Kirby
My problem has been mentioned often enough: I want the possibility to keep text synchronized between Kirby and my editor. This affects both the text itself and its metadata.
To achieve this, I need a plugin for both Kirby and Obsidian. The Kirby plugin needs to provide an interface that allows me to read from and write to Kirby. The Obsidian plugin must execute these requests and work with the results, such as updating my local files.
I approached the whole thing experimentally in an extra Obsidian vault for that purpose. I wanted to play around a bit without having to fear data loss.
It has turned out that I need four endpoints that perform the following tasks:
- Update data in Kirby
- Update data in Obsidian
- Create a new page in Kirby
- Create a new page in Obsidian
In essence, this covers three cases:
- I created a new text in Obsidian and now want to publish it in Kirby.
- I have a text in Kirby that I haven't locally yet and want to "download" it into Obsidian.
- I have the text in both Obsidian and Kirby and want to update one of them.
The Obsidian plugin
As mentioned before, Obsidian plugins are written in TypeScript, which I like very much because I earn my living with it. So I got started and first looked at how everything works in Obsidian. Fortunately, everything is quite straightforward and with Ollama in my IDE, I made quick progress without much basic knowledge of the Obsidian API.
First, I had to figure out what I want to synchronize. Of course, the text itself, but also a handful of metadata. The problem here are the different formats in which metadata is stored. If they were identical, I wouldn't need any plugins and could probably just work directly with the files.
Obsidian stores metadata in the Frontmatter format. That's what it looks like right now when writing this article:
---
type: text
aliases:
date: 2024-12-05
channel: blog
status: draft
sync: false
slug:
title:
intro:
tags:
---
The representation in Obsidian looks like this:

Kirby, however, stores metadata in a slightly different format:
Title:
----
Intro:
----
Text:
----
Date:
----
Tags:
As you can see, the two differ slightly in their format and content. While metadata such as date and tags are written directly into the text file for both, Kirby identifies the page status (draft
, unlisted
, listed
) via the folder, thus not storing this information directly in the file.
Kirby does most of the translation work for me. Since I can use all Kirby classes and methods in the API, I don't have to worry about the format. I simply send all metadata and text as separate data sets.
I already have a Kirby plugin called "Internal API" in which I provide various endpoints to do small things or display information on the Mac. So I could simply extend the plugin. Ultimately, it's a plugin that provides a few routes.
Thanks to the relatively simple use case, I could go the classic CRUD1 way here. Actually, only CRU because I deliberately omitted deleting. Here's what the endpoints look like:
[
'pattern' => 'ENDPOINT/(:any)/(:any)/(:any)',
'method' => 'VERB',
'action' => function ($channel, $folder) {
$request = kirby()->request();
$requestData = $request->data();
$requestHeaders = $request->headers();
if ($requestHeaders['Authorization'] !== 'Bearer ' . option('mauricerenck.obsidian.token')) {
return new Response('Unauthorized', 'text/plain', 401);
}
// DO STUFF
return new Response(json_encode($data), 'application/json');
},
],
This route exists three times, with each differing in the Verb:
- Create ->
'method' => 'POST'
- Read ->
'method' => 'GET'
- Update ->
'method' => 'PUT'
The ENDPOINT
is actually named something else, of course.
I could have chosen a route here that catches all three cases, but I decided against it and opted for some code duplication to make everything clearer and ultimately more organized.
As you can see, each request is secured by a token. Every request must send the correct token in the header; otherwise it will be rejected. This way, I secure the endpoints; otherwise anyone could simply send and read data.
The structure
Before we continue, a few words about the underlying structure. On my website, I have various sections that I use to separate content somewhat. Essentially, these are Blog
, Hub
, Notes
and soon a fourth section. I'm calling them channels.
So that I don't have endless long directory lists, the respective channels are further divided in the blog and notes by year and in the hub by topics.
So the structure is:
/blog/2025/article-slug/template.en.md
or
/hub/built-with-kirby/article-slug/template.en.md
With a look at the metadata in Obsidian, you've probably already seen something similar:
channel: blog
slug: article-slug
A level is missing, the year or topic. Here I decided to rebuild the structure in Obsidian. If you look at the Obsidian directory tree, it is identical to the Kirby structure:

The ATTACHMENTS directory can be ignored, here Obsidian stores linked files, such as images.
As you can see, there are yearly directories here, just like in Kirby.
This is how I then build the API endpoint URL in the Obsidian plugin:
const options = {
url: `${this.settings.apiBaseUrl}/${channel}/${folder}/${slug}`,
method: 'POST|PUT|GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.settings.apiToken}`
},
body: JSON.stringify(data)
};
We have a base URL, that is the URL of my website plus the aforementioned ENDPOINT. Followed by the channel, e.g., blog
, the folder
, for this post, therefore 2025
. Finally, the slug
. The slug is ultimately the filename of the Markdown file in Obsidian, in Kirby it's the directory name of the post.
Case 1: Create a new page
One use-case is creating a new page in Kirby. Given that I've already written a text in Obsidian. This should now – we're sticking with the example – be published on the blog.
To avoid synchronizing what shouldn't be synchronized, I built a sync checkbox. Only when this is activated can a file be synchronized at all. You can see this further up in the screenshot.
Overall: The question of what, when, and how is synchronized, and whether it has already been synchronized, came up quite early.
Here, a mixture of different statuses and the sync checkbox are at work.

"how-to-burn-money" is unsynchronized and filed as an idea. This means there are usually only a few bullet points and I don't yet know if it will ever become a text.
"obsidian-kirby-sync" is already in progress, but has not been synchronized. It exists as a new file, but hasn't found its way to Kirby yet.
"example 2" is synchronized and is a draft
in Kirby.
"example" is synchronized and published, but not listed.
"sociabli" is synchronized, published, and visible on the blog.
In our example, we have an article in progress, which has not yet been synchronized. Therefore, in the plugin, we must first collect some data locally so that we can send it to Kirby:
const data = await this.readFileData(file);
const channel = data.frontmatter.channel;
const currentYear = new Date().getFullYear();
const folder = file.parent?.name || currentYear.toString();
try {
// REQUEST CODE FROM ABOVE WITH THIS CHANGES
// url: `${this.settings.apiBaseUrl}/${channel}/${folder}`,
// method: 'POST',
const response = await requestUrl(options);
if (response.status !== 200) {
console.error(`Failed to fetch API data from ${channel}`);
return \{\};
}
this.syncFile(file, response.json);
}
First we read the current Markdown file, retrieve the channel from the Frontmatter data and get the current year. Finally, we define how the folder should be named. Does the file have a parent directory? Then we take its name, otherwise the year. The last case shouldn't actually ever happen, but I want to be safe here.
How the actual API call works, I've already shown above. Because we are creating a new article, it is a POST
request.
If the call fails for any reason, the error will be logged and the process aborted. If everything went well, we get the stored and enriched data back from the API.
If something goes utterly wrong, the catch
block kicks in and raises the alarm.
catch (error) {
console.error(`Error: Could not create page`);
new Notice(`Error: Could not create page`);
this.updateStatus('Error: Could not create page');
return \{\};
}
First, the error is logged, I also show a notification, and at the bottom of the Obsidian status bar there's also a message. This should be hard to overlook.
Kirby plugin
As shown in the route above, Kirby receives the data and checks the token. Channel, folder, and slug are passed via the URL and directly into the route, the text is in the POST body and must be read out.
Next, I check if the folder even exists. If not, I stop immediately:
$parent = kirby()->page($channel . '/' . $folder);
if (is_null($parent)) {
return new Response('Not Found', 'text/plain', 404);
}
Now it's time to bring the data into the Kirby format and save it.
$newData = [
'title' => $requestData['frontmatter']['title'],
'intro' => $requestData['frontmatter']['intro'],
'text' => $requestData['content'],
'tags' => $requestData['frontmatter']['tags'],
];
kirby()->impersonate('kirby');
$page = Page::create([
'parent' => $parent,
'slug' => Str::slug($requestData['frontmatter']['title']),
'template' => 'post',
'content' => $newData
]);
I'm building an array here that I can pass to the Page::create
call. In addition, I'm also generating a slug from the title here. My standard post
template serves as the template.
This creates a new page in Kirby and returns it directly to me, so I can immediately access it via $page
.
First, though, I'm checking what status the page should have. I could set it up in Obsidian so that it's published directly:
if (in_array($requestData['frontmatter']['status'], ['listed', 'unlisted'])) {
$page->changeStatus($requestData['frontmatter']['status']);
}
The initial state is always draft
, so I only need to check for listed
and unlisted
here. If one of them is set, the page is published accordingly.
Now we are enriching the data further and sending it back to Obsidian:
$data = [
'frontmatter' => [
'tags' => $page->tags()->split(','),
'date' => $page->date()->toDate('c'),
'status' => $page->status(),
'title' => $page->title()->value(),
'intro' => $page->intro()->value(),
'sync' => true,
'slug' => $page->slug(),
'channel' => $channel,
],
'modified' => $page->modified('c'),
'content' => $page->text()->value(),
'folder' => $page->parent()->uid(),
'headers' => $requestHeaders,
];
return new Response(json_encode($data), 'application/json');
It's important that I return the date and slug because Obsidian didn't have this information before. There is a method in the Obsidian plugin to update the local file which receives this data.
Receiving data in Obsidian
To prevent unnecessary updates and infinite loops, I first check if there are any changes at all:
const originalContent = await this.app.vault.read(file);
const frontmatter = this.app.metadataCache.getFileCache(file)?.frontmatter;
let updatedContent = originalContent;
I read out the current, local status. I write this to updatedcontent
, which seems a bit odd, but it will be explained soon.
First, let's get to the metadata:
if (frontmatter) {
const frontmatterEndIndex = originalContent.indexOf("---", 3) + 3;
const existingFrontmatter = parseYaml(originalContent.slice(3, frontmatterEndIndex - 3));
const updatedFrontmatter = { ...existingFrontmatter, ...apiData.frontmatter };
const newFrontmatterBlock = `---\n${stringifyYaml(updatedFrontmatter)}---`;
updatedContent = newFrontmatterBlock + originalContent.slice(frontmatterEndIndex);
} else {
apiData.frontmatter.sync = true;
updatedContent = `---\n${stringifyYaml(apiData.frontmatter)}---\n${originalContent}`;
}
I first create an object out of the Frontmatter text block. Then I simply overwrite it with the data from the API. I don't care about individual fields here, I'm pretty risk-taking. In the end, I write the data to updatedContent
and append the text below.
If the file doesn't have any Frontmatter at all, I take the API data 1:1. This comes into play later when we create a new file in Obsidian with the data from Kirby.
Now I'm finally updating the actual content:
if (apiData.content) {
const frontmatterEndIndex = updatedContent.indexOf("---", 3) + 3;
updatedContent = updatedContent.slice(0, frontmatterEndIndex) + "\n" + apiData.content;
}
Here, I just take what the API delivers.
Now to the tricky part. The local filename should always correspond to the remote slug, so we have to work on that:
const newSlug = apiData.frontmatter.slug;
const currentTitle = file.name.replace(/\.md$/, "");
console.log(`Checking slug ${file.path} vs ${newSlug}.md`);
if(apiData.folder !== file.parent?.name) {
console.log(`Moving file ${file.path} to ${apiData.folder}`);
await this.moveFile(file, apiData.folder, newSlug);
} else if (newSlug && newSlug !== currentTitle) {
console.log(`Renaming file ${file.path} to ${newSlug}.md`);
await this.renameFile(file, newSlug);
}
I grab the slug from the API. I grab the current file name and remove the extension. If the folder has changed, I move the file and rename it after the new slug. If only the slug has changed, I rename the file.
This already anticipates a bit what we need for other syncs.
Finally, we write the file, if necessary:
if (originalContent !== updatedContent) {
this.pluginStatus == "pulled"
await this.app.vault.modify(file, updatedContent);
}
this.updateStatus('Sync Complete');
Case 2: Downloading a page
The second case deals with the opposite direction. There is already a page in Kirby, and I now want it in Obsidian as well. This usually is the case for older articles that I wrote before I had the plugins running.
For the Kirby part, this is the simplest case. The route listens to the channel, folder, and slug. It's a GET
request. As always, the token is checked first, and then I retrieve the page:
$page = kirby()->page($channel . '/' . $folder . '/' . $slug);
if (is_null($page)) {
return new Response('Not Found', 'text/plain', 404);
}
If it doesn't exist, it is acknowledged accordingly and the process ends. If the page exists, the same array as in Create
is generated and returned to Obsidian.
To make this work, I need the possibility to specify folders and slug etc. in Obsidian, there is a dialog for that:

Very spartanic, I know, it's only supposed to work right now. I need Kirby's slug here. So I have to look into the panel. That's not quite optimal, but since the case is very rare, that's okay for me.
If I click submit
the GET-Request will be sent:
const options = {
url: `${this.settings.apiBaseUrl}/${channel}/${folder}/${slug}`,
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.settings.apiToken}`
},
};
Because I get the same data structure back here as with Create
, I can simply call the same method at the end of case 1 in case of success. The logic remains the same. Kirby sends me all the data back, especially the slug, which is now used to name the file accordingly and possibly move it to the correct directory.
Case 3: Update Kirby with local data
In this case, both ends are already syncing. I now want to send targeted local changes to Kirby. A PUT
request. On the Kirby side, this works almost exactly like a POST
request.
I fetch the affected page again, create an array with the new data, and then call an update
instead of a create
:
$page->update($newData);
After that, I return the page data like during the create
and Obsidian can update the local file if necessary.
Case 4: Update the local file
The last case. Here both ends are already syncing and we want to now update the changes I made in the panel locally as well.
Locally, we already have all the data we need to make the GET
request. A piece of cake for Kirby. I get the page, read the data and deliver it again in the familiar format back to Obsidian.
Well, and Obsidian can then simply use the method from before to update the file again.
Fine-tuning
Boldly, I initially decided to always perform a sync and retrieve data from Kirby when opening a file locally (if the checkbox was set, of course).
I thought I could take the risk because I had Obsidian listening for changes and synchronizing towards Kirby whenever Obsidian saved the file.
This was a bit too bold and in production it led to data loss. I had written a lot locally, remotely there was still a "test" in the text, and somehow I managed to open the file without sending it to Kirby beforehand. Result: My text was gone, a cheerful "test" laughed at me. Thanks to Obsidian Sync, I could then restore the last state.
I then decided against automatic syncing...
From the beginning, I had the option to start a sync with a command. To achieve this, I open the command line with cmd+p
and type kirby
. Then I can choose what I want to do:

As you can see, all four cases are listed here. For "Create page from remote", the dialog shown above for entering the slug is openend. Otherwise, the described processes are triggered.
Such a command can be created in Obsidian as follows:
this.addCommand({
id: 'kirby-pull-remote',
name: 'Update local file',
checkCallback: (checking: boolean) => {
const activeFile = this.app.workspace.getActiveFile();
if (activeFile && activeFile.extension === "md") {
if (!checking) {
this.pluginStatus = "pulling";
this.handleFileModify(activeFile, true);
}
return true;
}
return false;
}
});
A few check to make sure all prerequisites are met, then the sync is called. The other commands work similarly.
Finally, I'm also using a plugin that lets me give files icons. I use this plugin to set a suitable icon based on the file status, as you can see in the screenshot above.
Conclusion
I'm very satisfied with this setup so far. I always have everything on the same level. Thanks to Obsidian Sync, my plugin runs on all devices.
Of course, a little more happens, at the top you can already see the pluginStatus
, which ensures that there are no loops and nothing overlaps. In Kirby, I convert the Obsidian syntax for images into a Kirbytag. From ! [ [obsidian-8.png ] ]
it becomes ( image: obsidian-8.png )
when the Page is rendered in Kirby (I added the spaces so that the tags are not replaced).
Speaking of pictures! I don't synchronize them yet, so they have to be uploaded extra. That would be a nice feature for the future.
So I can merrily synchronize, my Obsidian looks great thanks to the theme and font, and I can write very pleasantly there. Thanks to Obsidian Sync, I'm not afraid of data loss and can access my Vault on all devices. I have good style and grammar checking via the plugin, and I can see the status of my files at a glance in the directory tree.
There is still massive potential for improvement. All of this is hacked together wildly because I've tried a lot and learned a lot. That's why both plugins are still a bit far from being publishable. And I'm not even sure how much need there is for that at all. You could write me a comment.
-
CRUD stands for Create, Read, Update, Delete. ↩
Write a comment