diff --git a/.gitignore b/.gitignore
index 410621b..30bddff 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
.direnv/
.pre-commit-config.yaml
+public/
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..9f9fb92
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "themes/terminus"]
+ path = themes/terminus
+ url = https://github.com/ebkalderon/terminus.git
diff --git a/content/_index.md b/content/_index.md
new file mode 100644
index 0000000..8f64f6d
--- /dev/null
+++ b/content/_index.md
@@ -0,0 +1,19 @@
+---
+title: test
+---
+
+# Hi, I'm Jakob
+
+I write code in Gothenburg, Sweden.
+
+## About
+
+I work on e-commerce stuff at [Norce](https://www.norce.io/), mainly .NET and TypeScript. In my spare time I mostly do Rust and TypeScript, but I dabble in whatever I find interesting. All my machines run [Nix](https://github.com/ankarhem/nix-config).
+
+## Links
+
+- [Email](mailto:jakob@ankarhem.dev)
+- [GPG key](/ankarhem.asc) (fingerprint: `445B B655 C1B5 B5CD AA92 8D1C 5299 72E4 1602 00DF`)
+- GitHub
+- Mastodon
+- LinkedIn
diff --git a/content/posts/_index.md b/content/posts/_index.md
new file mode 100644
index 0000000..6c432cb
--- /dev/null
+++ b/content/posts/_index.md
@@ -0,0 +1,3 @@
+---
+title: "Posts"
+---
diff --git a/content/posts/declarative-skills-with-nix.md b/content/posts/declarative-skills-with-nix.md
new file mode 100644
index 0000000..cf89ef1
--- /dev/null
+++ b/content/posts/declarative-skills-with-nix.md
@@ -0,0 +1,103 @@
+---
+title: Declarative AI Skills with Nix Flake Inputs
+date: 2026-06-06
+slug: declarative-skills-with-nix
+---
+
+# Declarative AI Skills with Nix Flake Inputs
+
+I have skills scattered across four repositories. Company ones, personal ones, a few from open-source projects. When I started using AI coding agents, I already had a Nix-based setup for everything else, so doing the same for skills was obvious.
+
+Here's how I pull them in as flake inputs, pick the ones I want, and merge them into `~/.agents/skills`.
+
+## What It Looks Like
+
+First, the repositories as flake inputs. Two are actual Nix flakes, two are just regular repos with markdown files. `flake = false` tells Nix to skip evaluating them as flakes and just fetch the source:
+
+```nix
+inputs = {
+ agent-browser.url = "github:vercel-labs/agent-browser";
+ agent-browser.flake = false;
+
+ norce-agent-instructions.url = "github:NorceTech/agent-instructions";
+ norce-agent-instructions.flake = false;
+
+ graylog-cli.url = "github:norcetech/graylog-cli";
+ graylog-cli.flake = false;
+
+ temporalio-skill.url = "github:temporalio/skill-temporal-developer";
+ temporalio-skill.flake = false;
+};
+```
+
+Then a small helper that reads a directory and returns all subdirectories as an attribute set. Given a directory like `skills/` with `argue/`, `commit/`, and `jira/` inside, it returns:
+
+```nix
+{
+ argue = "/nix/store/.../skills/argue";
+ commit = "/nix/store/.../skills/commit";
+ jira = "/nix/store/.../skills/jira";
+}
+```
+
+Here's the helper:
+
+```nix
+readSkillsFrom = dir:
+ builtins.mapAttrs (name: _: dir + "/${name}") (
+ lib.filterAttrs (_: type: type == "directory") (builtins.readDir dir)
+ );
+```
+
+`builtins.readDir dir` gives `{ argue = "directory"; commit = "directory"; ... }`. `filterAttrs` drops files, keeping only directories. `mapAttrs` strips the `"directory"` value and replaces it with the full path. The result is a mapping from skill name to absolute path, which is exactly what `linkFarm` wants.
+
+And the actual merging:
+
+```nix
+home.file.".agents/skills".source =
+ let
+ localSkills = readSkillsFrom ./skills;
+ agentBrowserSkills = readSkillsFrom "${inputs.agent-browser}/skills";
+ norceSkills = readSkillsFrom "${inputs.norce-agent-instructions}/skills";
+ graylogSkills = readSkillsFrom "${inputs.graylog-cli}/skills";
+ in
+ pkgs.linkFarm "merged-skills" (
+ localSkills
+ // (lib.getAttrs [ "agent-browser" ] agentBrowserSkills)
+ // (lib.getAttrs [
+ "argue"
+ "checkout-config"
+ "commit"
+ "create-pr"
+ "git-bisect"
+ "jira"
+ "using-git-worktrees"
+ ] norceSkills)
+ // (lib.getAttrs [ "graylog-cli" ] graylogSkills)
+ // {
+ "temporal-developer" = inputs.temporalio-skill;
+ }
+ );
+```
+
+`linkFarm` creates a directory of symlinks. After switching, `~/.agents/skills` looks like this:
+
+```
+~/.agents/skills/
+├── agent-browser/ → agent-browser repo/skills/agent-browser/
+├── argue/ → norce repo/skills/argue/
+├── checkout-config/ → norce repo/skills/checkout-config/
+├── commit/ → norce repo/skills/commit/
+├── graylog-cli/ → graylog-cli repo/skills/graylog-cli/
+├── jira/ → norce repo/skills/jira/
+├── my-local-skill/ → ./skills/my-local-skill/
+└── temporal-developer/ → temporalio-skill repo/
+```
+
+## Wrap Up
+
+The main thing I get from this is that skills update alongside everything else. I run `nix flake update` to pull new packages, new Home Manager modules, new system settings, and the skills come with them. I don't have a separate workflow for skills. I don't have to remember to check four different repositories for updates. If I'm already updating my config, the skills update too.
+
+Some of the skills are bundled with CLI tools I use. The `agent-browser` skill comes from the same repository as the `agent-browser` binary. When I update the binary, the skill updates automatically. They stay in sync because they're the same input. I don't have to worry that I updated the tool three weeks ago and the skill is still from January.
+
+This lives in the same repo as the rest of my configuration. The skills are not a separate thing I maintain in a different place with different rules. They're just inputs in my flake, same as my editor config, my shell, or my Git settings. I can roll back my entire system to a previous commit and the skills roll back with it. That's not a feature of skill management. It's just Nix being Nix.
diff --git a/public/elasticlunr.min.js b/public/elasticlunr.min.js
deleted file mode 100644
index 79dad65..0000000
--- a/public/elasticlunr.min.js
+++ /dev/null
@@ -1,10 +0,0 @@
-/**
- * elasticlunr - http://weixsong.github.io
- * Lightweight full-text search engine in Javascript for browser search and offline search. - 0.9.6
- *
- * Copyright (C) 2017 Oliver Nightingale
- * Copyright (C) 2017 Wei Song
- * MIT Licensed
- * @license
- */
-!function(){function e(e){if(null===e||"object"!=typeof e)return e;var t=e.constructor();for(var n in e)e.hasOwnProperty(n)&&(t[n]=e[n]);return t}var t=function(e){var n=new t.Index;return n.pipeline.add(t.trimmer,t.stopWordFilter,t.stemmer),e&&e.call(n,n),n};t.version="0.9.5",lunr=t,t.utils={},t.utils.warn=function(e){return function(t){e.console&&console.warn&&console.warn(t)}}(this),t.utils.toString=function(e){return void 0===e||null===e?"":e.toString()},t.EventEmitter=function(){this.events={}},t.EventEmitter.prototype.addListener=function(){var e=Array.prototype.slice.call(arguments),t=e.pop(),n=e;if("function"!=typeof t)throw new TypeError("last argument must be a function");n.forEach(function(e){this.hasHandler(e)||(this.events[e]=[]),this.events[e].push(t)},this)},t.EventEmitter.prototype.removeListener=function(e,t){if(this.hasHandler(e)){var n=this.events[e].indexOf(t);-1!==n&&(this.events[e].splice(n,1),0==this.events[e].length&&delete this.events[e])}},t.EventEmitter.prototype.emit=function(e){if(this.hasHandler(e)){var t=Array.prototype.slice.call(arguments,1);this.events[e].forEach(function(e){e.apply(void 0,t)},this)}},t.EventEmitter.prototype.hasHandler=function(e){return e in this.events},t.tokenizer=function(e){if(!arguments.length||null===e||void 0===e)return[];if(Array.isArray(e)){var n=e.filter(function(e){return null===e||void 0===e?!1:!0});n=n.map(function(e){return t.utils.toString(e).toLowerCase()});var i=[];return n.forEach(function(e){var n=e.split(t.tokenizer.seperator);i=i.concat(n)},this),i}return e.toString().trim().toLowerCase().split(t.tokenizer.seperator)},t.tokenizer.defaultSeperator=/[\s\-]+/,t.tokenizer.seperator=t.tokenizer.defaultSeperator,t.tokenizer.setSeperator=function(e){null!==e&&void 0!==e&&"object"==typeof e&&(t.tokenizer.seperator=e)},t.tokenizer.resetSeperator=function(){t.tokenizer.seperator=t.tokenizer.defaultSeperator},t.tokenizer.getSeperator=function(){return t.tokenizer.seperator},t.Pipeline=function(){this._queue=[]},t.Pipeline.registeredFunctions={},t.Pipeline.registerFunction=function(e,n){n in t.Pipeline.registeredFunctions&&t.utils.warn("Overwriting existing registered function: "+n),e.label=n,t.Pipeline.registeredFunctions[n]=e},t.Pipeline.getRegisteredFunction=function(e){return e in t.Pipeline.registeredFunctions!=!0?null:t.Pipeline.registeredFunctions[e]},t.Pipeline.warnIfFunctionNotRegistered=function(e){var n=e.label&&e.label in this.registeredFunctions;n||t.utils.warn("Function is not registered with pipeline. This may cause problems when serialising the index.\n",e)},t.Pipeline.load=function(e){var n=new t.Pipeline;return e.forEach(function(e){var i=t.Pipeline.getRegisteredFunction(e);if(!i)throw new Error("Cannot load un-registered function: "+e);n.add(i)}),n},t.Pipeline.prototype.add=function(){var e=Array.prototype.slice.call(arguments);e.forEach(function(e){t.Pipeline.warnIfFunctionNotRegistered(e),this._queue.push(e)},this)},t.Pipeline.prototype.after=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var i=this._queue.indexOf(e);if(-1===i)throw new Error("Cannot find existingFn");this._queue.splice(i+1,0,n)},t.Pipeline.prototype.before=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var i=this._queue.indexOf(e);if(-1===i)throw new Error("Cannot find existingFn");this._queue.splice(i,0,n)},t.Pipeline.prototype.remove=function(e){var t=this._queue.indexOf(e);-1!==t&&this._queue.splice(t,1)},t.Pipeline.prototype.run=function(e){for(var t=[],n=e.length,i=this._queue.length,o=0;n>o;o++){for(var r=e[o],s=0;i>s&&(r=this._queue[s](r,o,e),void 0!==r&&null!==r);s++);void 0!==r&&null!==r&&t.push(r)}return t},t.Pipeline.prototype.reset=function(){this._queue=[]},t.Pipeline.prototype.get=function(){return this._queue},t.Pipeline.prototype.toJSON=function(){return this._queue.map(function(e){return t.Pipeline.warnIfFunctionNotRegistered(e),e.label})},t.Index=function(){this._fields=[],this._ref="id",this.pipeline=new t.Pipeline,this.documentStore=new t.DocumentStore,this.index={},this.eventEmitter=new t.EventEmitter,this._idfCache={},this.on("add","remove","update",function(){this._idfCache={}}.bind(this))},t.Index.prototype.on=function(){var e=Array.prototype.slice.call(arguments);return this.eventEmitter.addListener.apply(this.eventEmitter,e)},t.Index.prototype.off=function(e,t){return this.eventEmitter.removeListener(e,t)},t.Index.load=function(e){e.version!==t.version&&t.utils.warn("version mismatch: current "+t.version+" importing "+e.version);var n=new this;n._fields=e.fields,n._ref=e.ref,n.documentStore=t.DocumentStore.load(e.documentStore),n.pipeline=t.Pipeline.load(e.pipeline),n.index={};for(var i in e.index)n.index[i]=t.InvertedIndex.load(e.index[i]);return n},t.Index.prototype.addField=function(e){return this._fields.push(e),this.index[e]=new t.InvertedIndex,this},t.Index.prototype.setRef=function(e){return this._ref=e,this},t.Index.prototype.saveDocument=function(e){return this.documentStore=new t.DocumentStore(e),this},t.Index.prototype.addDoc=function(e,n){if(e){var n=void 0===n?!0:n,i=e[this._ref];this.documentStore.addDoc(i,e),this._fields.forEach(function(n){var o=this.pipeline.run(t.tokenizer(e[n]));this.documentStore.addFieldLength(i,n,o.length);var r={};o.forEach(function(e){e in r?r[e]+=1:r[e]=1},this);for(var s in r){var u=r[s];u=Math.sqrt(u),this.index[n].addToken(s,{ref:i,tf:u})}},this),n&&this.eventEmitter.emit("add",e,this)}},t.Index.prototype.removeDocByRef=function(e){if(e&&this.documentStore.isDocStored()!==!1&&this.documentStore.hasDoc(e)){var t=this.documentStore.getDoc(e);this.removeDoc(t,!1)}},t.Index.prototype.removeDoc=function(e,n){if(e){var n=void 0===n?!0:n,i=e[this._ref];this.documentStore.hasDoc(i)&&(this.documentStore.removeDoc(i),this._fields.forEach(function(n){var o=this.pipeline.run(t.tokenizer(e[n]));o.forEach(function(e){this.index[n].removeToken(e,i)},this)},this),n&&this.eventEmitter.emit("remove",e,this))}},t.Index.prototype.updateDoc=function(e,t){var t=void 0===t?!0:t;this.removeDocByRef(e[this._ref],!1),this.addDoc(e,!1),t&&this.eventEmitter.emit("update",e,this)},t.Index.prototype.idf=function(e,t){var n="@"+t+"/"+e;if(Object.prototype.hasOwnProperty.call(this._idfCache,n))return this._idfCache[n];var i=this.index[t].getDocFreq(e),o=1+Math.log(this.documentStore.length/(i+1));return this._idfCache[n]=o,o},t.Index.prototype.getFields=function(){return this._fields.slice()},t.Index.prototype.search=function(e,n){if(!e)return[];e="string"==typeof e?{any:e}:JSON.parse(JSON.stringify(e));var i=null;null!=n&&(i=JSON.stringify(n));for(var o=new t.Configuration(i,this.getFields()).get(),r={},s=Object.keys(e),u=0;u0&&t.push(e);for(var i in n)"docs"!==i&&"df"!==i&&this.expandToken(e+i,t,n[i]);return t},t.InvertedIndex.prototype.toJSON=function(){return{root:this.root}},t.Configuration=function(e,n){var e=e||"";if(void 0==n||null==n)throw new Error("fields should not be null");this.config={};var i;try{i=JSON.parse(e),this.buildUserConfig(i,n)}catch(o){t.utils.warn("user configuration parse failed, will use default configuration"),this.buildDefaultConfig(n)}},t.Configuration.prototype.buildDefaultConfig=function(e){this.reset(),e.forEach(function(e){this.config[e]={boost:1,bool:"OR",expand:!1}},this)},t.Configuration.prototype.buildUserConfig=function(e,n){var i="OR",o=!1;if(this.reset(),"bool"in e&&(i=e.bool||i),"expand"in e&&(o=e.expand||o),"fields"in e)for(var r in e.fields)if(n.indexOf(r)>-1){var s=e.fields[r],u=o;void 0!=s.expand&&(u=s.expand),this.config[r]={boost:s.boost||0===s.boost?s.boost:1,bool:s.bool||i,expand:u}}else t.utils.warn("field name in user configuration not found in index instance fields");else this.addAllFields2UserConfig(i,o,n)},t.Configuration.prototype.addAllFields2UserConfig=function(e,t,n){n.forEach(function(n){this.config[n]={boost:1,bool:e,expand:t}},this)},t.Configuration.prototype.get=function(){return this.config},t.Configuration.prototype.reset=function(){this.config={}},lunr.SortedSet=function(){this.length=0,this.elements=[]},lunr.SortedSet.load=function(e){var t=new this;return t.elements=e,t.length=e.length,t},lunr.SortedSet.prototype.add=function(){var e,t;for(e=0;e1;){if(r===e)return o;e>r&&(t=o),r>e&&(n=o),i=n-t,o=t+Math.floor(i/2),r=this.elements[o]}return r===e?o:-1},lunr.SortedSet.prototype.locationFor=function(e){for(var t=0,n=this.elements.length,i=n-t,o=t+Math.floor(i/2),r=this.elements[o];i>1;)e>r&&(t=o),r>e&&(n=o),i=n-t,o=t+Math.floor(i/2),r=this.elements[o];return r>e?o:e>r?o+1:void 0},lunr.SortedSet.prototype.intersect=function(e){for(var t=new lunr.SortedSet,n=0,i=0,o=this.length,r=e.length,s=this.elements,u=e.elements;;){if(n>o-1||i>r-1)break;s[n]!==u[i]?s[n]u[i]&&i++:(t.add(s[n]),n++,i++)}return t},lunr.SortedSet.prototype.clone=function(){var e=new lunr.SortedSet;return e.elements=this.toArray(),e.length=e.elements.length,e},lunr.SortedSet.prototype.union=function(e){var t,n,i;this.length>=e.length?(t=this,n=e):(t=e,n=this),i=t.clone();for(var o=0,r=n.toArray();o