{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "Niclas Lindstedt's blog",
  "description": "Writing about AI, agents, and open source — hands-on notes from building tools like zag, zad, ztf, zig, and oss-spec.",
  "home_page_url": "https://blog.niclaslindstedt.se/",
  "feed_url": "https://blog.niclaslindstedt.se/feed.json",
  "language": "en",
  "authors": [
    {
      "name": "Niclas Lindstedt",
      "url": "https://niclaslindstedt.se"
    }
  ],
  "items": [
    {
      "id": "https://blog.niclaslindstedt.se/posts/2026-05-07-my-cv-is-json-not-a-document/",
      "url": "https://blog.niclaslindstedt.se/posts/2026-05-07-my-cv-is-json-not-a-document/",
      "title": "My CV is JSON, not a document",
      "summary": "One schema-validated JSON object reprojected into a React site, two PDFs, an OG image, a sitemap, a search index, and an /llms.txt for agents.",
      "content_html": "<p><a href=\"https://github.com/niclaslindstedt/cv\">cv</a> is my personal site and résumé, live at <a href=\"https://niclaslindstedt.se\">niclaslindstedt.se</a>. The interesting bit isn&#x27;t the React app — it&#x27;s that the CV is a single JSON object, schema-validated at build time, and re-projected into every visible artifact: the rendered site, a bilingual PDF, an OG share image, a sitemap, an in-page search index, and a couple of agent-facing files at <code>/resume.json</code> and <code>/llms.txt</code>.</p>\n<h2>The data, and why an agent can edit it</h2>\n<p>The CV data lives across a handful of JSON files — one per category, like <code>projects.json</code>, <code>experience.json</code>, <code>companies.json</code>, <code>skills.json</code>. They get merged at build time into one assembled object, and everything else reads from that: the React app, the PDF pipeline, the validator, the <code>/resume.json</code> generator. One shape, one source.</p>\n<p>A small validator<!-- -->&lt;sup&gt;<a href=\"https://github.com/niclaslindstedt/cv/blob/7e1f94a4a53fa0c1c37aeb21024fc16bf8a570b3/scripts/validate-cv.mjs\">1</a>&lt;/sup&gt;<!-- --> runs the assembled object through the JSON Schema in <code>schemas/cv.schema.json</code> before anything ships. Anything that breaks the schema fails the build.</p>\n<p>That&#x27;s why a coding agent can update it. The schema is the contract; the validator tells the agent whether an edit is well-formed before it lands. To bootstrap the data I pasted in an old CV and let the agent turn it into the structured shape — from there it&#x27;s just normal editing. Updating Word PDFs has always been a pain and working with Word files through an agent is not optimal — it never looks as good as I want it to. The CV project has been iterated on until it just feels right.</p>\n<p>An <code>update-cv</code> skill in the repo<!-- -->&lt;sup&gt;<a href=\"https://github.com/niclaslindstedt/cv/blob/7e1f94a4a53fa0c1c37aeb21024fc16bf8a570b3/.agent/skills/update-cv/SKILL.md\">2</a>&lt;/sup&gt;<!-- --> codifies the editing rules so the agent doesn&#x27;t have to guess. It picks the right file, runs the validator before declaring done, and — because most user-facing strings are <code>{ &quot;en&quot;: ..., &quot;sv&quot;: ... }</code> pairs — writes the Swedish version alongside the English whenever I add or revise a description. Both end up at the same level of polish; the Swedish copy isn&#x27;t a translation pass tacked on after the fact.</p>\n<h2>Local overrides</h2>\n<p>One layer doesn&#x27;t ship publicly. <code>cv.local.json</code> is gitignored and deep-merged on top of the assembled CV when <code>CV_LOCAL=1</code> is set — full contact details, longer descriptions, anything I don&#x27;t want indexed on the open web. <code>make local</code> runs the build with the override active and produces a separate set of PDFs under a different filename. That&#x27;s the version I actually send when I apply for a job; the public site stays scrubbed.</p>\n<h2>Side-project commit stats</h2>\n<p>Not all the CV data is hand-written. Per-project commit stats — total commits, first and last commit dates, year-by-year activity — get pulled from the GitHub GraphQL API at build time<!-- -->&lt;sup&gt;<a href=\"https://github.com/niclaslindstedt/cv/blob/7e1f94a4a53fa0c1c37aeb21024fc16bf8a570b3/scripts/generate-project-stats.mjs\">3</a>&lt;/sup&gt;<!-- -->. For open-source projects, only my own commits count toward the totals, not those of every contributor. The numbers show up on each project card and are inlined into <code>/resume.json</code> so an agent fetching the JSON gets the activity data without an extra API roundtrip.</p>\n<p>Token-less builds (a fresh clone, an external contributor&#x27;s PR, an ad-hoc local PDF) fall back to a cached copy on a separate <code>data-cache</code> git branch that a scheduled workflow refreshes. The CV doesn&#x27;t blow up because GitHub returned a 401.</p>\n<h2>Progressive disclosure</h2>\n<p>The web CV exposes a lot, but it doesn&#x27;t shove it at you. Top level: the summary, the focus areas, the projects, the jobs, the skills, the degrees, the languages — each as a card. Click a project and you get the full description, the stack, the skills used, and external links. Click an education entry and you get the program structure, the courses, the credit transfers. The course lists for an entire degree are right there if you want them, but they&#x27;re behind a click — don&#x27;t click programs if you don&#x27;t want course lists. The front page stays scannable; the depth is there for the readers who actually want it. The PDF respects the same idea by collapsing the deep stuff entirely.</p>\n<h2>The timeline</h2>\n<p>The timeline page does something a flat CV can&#x27;t: it shows how things relate to each other in time. Overlapping engagements, parallel side projects, gaps and clusters — all visible at a glance. It&#x27;s a separate page (<code>/timeline</code>), but it reads from the same <code>experience.json</code> and <code>projects.json</code> that everything else does.</p>\n<h2>Search</h2>\n<p>A search modal sits one keystroke away on the main résumé page. Type a few letters and matches show up grouped by category — projects, jobs, skills, degrees. The index itself is generated at build time from the assembled CV<!-- -->&lt;sup&gt;<a href=\"https://github.com/niclaslindstedt/cv/blob/7e1f94a4a53fa0c1c37aeb21024fc16bf8a570b3/scripts/generate-search-index.mjs\">4</a>&lt;/sup&gt;<!-- --> and committed as static JSON, so what&#x27;s searchable is exactly what&#x27;s on the page — no separate authoring surface to drift. The ranker is hand-written; no third-party search library. Each searchable record carries a few hidden aliases so common abbreviations like <code>k8s</code>, <code>TS</code>, and <code>react</code> resolve to the right entry without cluttering the visible copy.</p>\n<h2>The other surfaces</h2>\n<p>That covers the site. The same assembled CV also drives:</p>\n<ul>\n<li>\n<p><strong>A bilingual PDF, English and Swedish.</strong> A simpler print-only layout gets rendered to static HTML, then opened in a headless browser and saved as a PDF<!-- -->&lt;sup&gt;<a href=\"https://github.com/niclaslindstedt/cv/blob/7e1f94a4a53fa0c1c37aeb21024fc16bf8a570b3/scripts/generate-pdf.mjs\">5</a>&lt;/sup&gt;<!-- -->. The PDF doesn&#x27;t go through the React app at all, so what comes out is identical every time. PDF metadata (Title, Author, Subject, Keywords) gets stamped on the way out so the file shows up correctly in document readers and search results.</p>\n</li>\n<li>\n<p><strong>A social share image</strong> — the 1200×630 banner that appears as the preview when someone pastes the URL into Slack, LinkedIn, Twitter, or iMessage. Generated from the same CV via <a href=\"https://github.com/vercel/satori\">satori</a>. Without one, social previews either fall back to a tiny favicon or skip the preview entirely. With one, the link looks like a polished card with my name, title, and tagline.</p>\n</li>\n<li>\n<p><strong>SEO scaffolding.</strong> A page nobody can find is a page that doesn&#x27;t exist. The site&#x27;s <code>&lt;head&gt;</code> carries Open Graph and Twitter card meta tags, a canonical URL, a meta description, and JSON-LD structured data describing me as a <code>Person</code> and the site as a <code>WebSite</code> — all derived from the same CV data, all injected at build time. That&#x27;s what Google reads when it decides what to show in search results, and what LinkedIn or Slack reads when generating a link preview.</p>\n</li>\n<li>\n<p><strong>Pre-rendered homepage HTML.</strong> A single-page React app normally ships an empty <code>&lt;div id=&quot;root&quot;&gt;</code> that the browser hydrates on load — which means crawlers and <code>curl</code> see a blank page. The build server-side renders the React tree once and bakes the result into <code>dist/index.html</code>, so no-JS clients get the actual résumé text and search engines index the real content.</p>\n</li>\n<li>\n<p><strong>A machine-readable <code>/resume.json</code>.</strong> The whole CV as a single JSON file, served straight from the site root. Agents can fetch the structured source instead of scraping HTML. <code>/cv.json</code> is a byte-identical alias for the path LLMs commonly guess. Both are discoverable via <code>robots.txt</code>, <code>sitemap.xml</code>, and <code>&lt;link rel=&quot;alternate&quot;&gt;</code> tags in the <code>&lt;head&gt;</code>.</p>\n</li>\n<li>\n<p><strong>An <code>/llms.txt</code> index</strong>&lt;sup&gt;<a href=\"https://github.com/niclaslindstedt/cv/blob/7e1f94a4a53fa0c1c37aeb21024fc16bf8a570b3/scripts/generate-llms-txt.mjs\">6</a>&lt;/sup&gt;<!-- --> following the <a href=\"https://llmstxt.org/\">llmstxt.org</a> convention. A small markdown file pointing agents at <code>/resume.json</code>, with the experience and side-project sections baked inline so an agent that only fetches this one file can still answer &quot;which jobs are listed there&quot;.</p>\n</li>\n<li>\n<p><strong>A sitemap.</strong> <code>dist/sitemap.xml</code> lists <code>/</code>, <code>/timeline</code>, <code>/resume.json</code>, <code>/cv.json</code>, and <code>/llms.txt</code>; <code>robots.txt</code> points at it. Generated alongside everything else so the URLs stay in sync if anything moves.</p>\n</li>\n</ul>\n<p>Two small build-time helpers tie the pipeline together. One merges the JSON parts into the single assembled object the rest of the build reads from. The other injects the SEO <code>&lt;head&gt;</code> block into the HTML at build time, derived from the same CV data.</p>\n<p>The build then runs the generators in sequence — type-check, bundle, then the PDF, <code>/resume.json</code>, <code>/llms.txt</code>, the timeline page, the sitemap — with a few pre-build steps for the timeline data, GitHub activity, and per-project commit stats. The data fetchers degrade gracefully: if a GitHub token isn&#x27;t configured, the per-project commit stats simply drop out instead of failing the build.</p>\n<h2>What&#x27;s actually new</h2>\n<p>Most of the building blocks are old — schemas, SSR, headless-Chromium PDFs. What I haven&#x27;t seen elsewhere is treating the schema as a guardrail an agent can edit against, and treating the LLM as a first-class consumer at the same level as the browser. Together those push the work out of &quot;writing a CV&quot; and into something I hand to an agent and review like code.</p>",
      "date_published": "2026-05-07T13:06:01Z",
      "date_modified": "2026-05-13T13:17:42Z",
      "tags": [
        "cv",
        "typescript",
        "react",
        "vite",
        "resume"
      ],
      "authors": [
        {
          "name": "Niclas Lindstedt",
          "url": "https://niclaslindstedt.se"
        }
      ]
    },
    {
      "id": "https://blog.niclaslindstedt.se/posts/2026-04-22-what-zag-is-and-why-it-exists/",
      "url": "https://blog.niclaslindstedt.se/posts/2026-04-22-what-zag-is-and-why-it-exists/",
      "title": "What zag is, and why it exists",
      "summary": "A meta-agent CLI that unifies Claude, Codex, Gemini, Copilot, and Ollama so swapping providers is a flag change.",
      "content_html": "<p><a href=\"https://github.com/niclaslindstedt/zag\">zag</a> is a meta-agent CLI with multiple language bindings that let devs code against a unified CLI, so switching between Codex and Claude is as simple as switching a flag. It&#x27;s written in Rust and published as <a href=\"https://crates.io/crates/zag-cli\"><code>zag-cli</code></a> on crates.io.</p>\n<h2>One CLI, every provider</h2>\n<p>New models are released every now and then, and most often the newest model is the best one. Unless it is a major jump, it doesn&#x27;t make sense to adapt all your processes to a different agent CLI, so mostly you are stuck with the worse model until your agent provider releases a new model. <a href=\"https://github.com/niclaslindstedt/zag\">zag</a> solves this by unifying the CLI interface for Claude, Codex, Gemini, Copilot, and Ollama. All parameters are mapped internally to the correct one for each agent flavor. <a href=\"https://github.com/niclaslindstedt/zag\">zag</a> goes further with size aliases: <code>-m small</code>, <code>-m medium</code>, <code>-m large</code> resolve to the right model per provider, so you can pick a class of model without memorising each vendor&#x27;s name for it. <code>-p auto -m auto</code> takes it another step — an LLM picks both the provider and the model for you. <a href=\"https://github.com/niclaslindstedt/zag\">zag</a> also unifies the output: it tails the agent session logs and turns them into a <a href=\"https://github.com/niclaslindstedt/zag\">zag</a> session log.</p>\n<h2>Language bindings</h2>\n<p><a href=\"https://github.com/niclaslindstedt/zag\">zag</a> has language bindings for TypeScript, Python, C#, Swift, Java, and Kotlin, helping users programmatically call the agent CLI of their choice. These bindings wrap the <a href=\"https://github.com/niclaslindstedt/zag\">zag</a> CLI under the hood, except for the native Rust binding, which re-exports the workspace crates directly with zero subprocess overhead.</p>\n<p>Each binding exposes a fluent builder with typed output and a live event stream, so you get the agent&#x27;s responses, tool calls, and thinking as structured events in your language of choice instead of parsing stdout yourself. The <a href=\"https://github.com/niclaslindstedt/zag/tree/main/examples\">examples/</a> directory shows a few things this unlocks:</p>\n<ul>\n<li><strong>ZagChat</strong> — a native macOS SwiftUI chat app on the Swift bindings, rendering streaming tool calls and sub-agent nesting live in the UI.</li>\n<li><strong>react-claude-interface</strong> — a React web app with a Claude Code-style chat interface, driven by <code>zag exec</code> and <code>zag input</code> and piping NDJSON events over Server-Sent Events.</li>\n<li><strong>cv-review</strong> — a Rust program that fans out parallel agent invocations to review CVs against job descriptions.</li>\n</ul>\n<p>The obvious one is an agent chat interface tailored to your own workflow, but anything that wants to run an agent from inside a larger program — a queue worker, a dashboard, an IDE plugin — fits.</p>\n<h2>Local, not cloud</h2>\n<p><a href=\"https://github.com/niclaslindstedt/zag\">zag</a> uses the actual CLIs and not SDKs. It&#x27;s meant to run on your own computer, not as a cloud service. That means you can use your subscription without spending 10x on API calls.</p>\n<h2>Orchestration primitives</h2>\n<p><a href=\"https://github.com/niclaslindstedt/zag\">zag</a> has a bunch of orchestration-enabling commands that make orchestration code a lot easier. The follow-up project <a href=\"https://github.com/niclaslindstedt/zig\">zig</a> — also a Rust CLI, published as <a href=\"https://crates.io/crates/zig-cli\"><code>zig-cli</code></a> — uses these to provide natural-language orchestration. You tell it what kind of workflow you need, and <a href=\"https://github.com/niclaslindstedt/zig\">zig</a> helps you set that up, using <a href=\"https://github.com/niclaslindstedt/zag\">zag</a> to avoid getting stuck with a specific provider.</p>\n<h2>Isolation per session</h2>\n<p><a href=\"https://github.com/niclaslindstedt/zag\">zag</a> ships isolation primitives that work the same across every provider. <code>-w / --worktree</code> runs the agent inside a dedicated git worktree so it can&#x27;t stomp on your checkout; <code>--sandbox</code> runs it inside a Docker microVM. Both are tracked per-session, so resuming restores the right workspace.</p>\n<h2>Remote access</h2>\n<p><a href=\"https://github.com/niclaslindstedt/zag\">zag</a> has a built-in client/server mode. <code>zag serve</code> starts an HTTPS/WebSocket server on one machine; <code>zag connect</code> on another makes all subsequent commands transparently proxy through. Leave the heavy lifting on your home machine and drive it from your work laptop. If the remote becomes unreachable, <a href=\"https://github.com/niclaslindstedt/zag\">zag</a> falls back to local execution automatically.</p>\n<h2>There&#x27;s a lot more</h2>\n<p>This post is the high-level pitch; most of what <a href=\"https://github.com/niclaslindstedt/zag\">zag</a> actually ships is features I haven&#x27;t touched yet. Some of the bigger ones I&#x27;ll come back to:</p>\n<ul>\n<li><strong>Structured output</strong> — <code>--json</code>, <code>--json-schema</code> with automatic retry, and NDJSON event streams.</li>\n<li><strong>Session management</strong> — named and tagged sessions, resume/continue, parent-child trees, per-project config.</li>\n<li><strong>Skills and MCP sync</strong> — write a skill or an MCP server definition once in <code>~/.zag/</code>, and <a href=\"https://github.com/niclaslindstedt/zag\">zag</a> syncs it into each provider&#x27;s native config format.</li>\n<li><strong>Observability</strong> — <code>zag listen</code>, <code>zag events</code>, <code>zag subscribe</code>, <code>zag watch</code>, <code>zag search</code> to tail, query, and react to a session&#x27;s event stream.</li>\n<li><strong>Provider downgrade</strong> — a tier-list fallback (<code>claude → codex → gemini → copilot → ollama</code>) that keeps scripts working when a CLI is missing or unauthed.</li>\n<li><strong>Review, plan, discover</strong> — first-class <code>zag review</code> and <code>zag plan</code>, plus <code>zag discover</code> to enumerate what each installed provider can actually do on this machine.</li>\n</ul>\n<p>I will write more about <a href=\"https://github.com/niclaslindstedt/zig\">zig</a> in later blog posts.</p>",
      "date_published": "2026-04-22T14:52:36Z",
      "date_modified": "2026-05-13T13:17:42Z",
      "tags": [
        "zag",
        "meta-agent",
        "cli",
        "rust"
      ],
      "authors": [
        {
          "name": "Niclas Lindstedt",
          "url": "https://niclaslindstedt.se"
        }
      ]
    },
    {
      "id": "https://blog.niclaslindstedt.se/posts/2026-04-22-how-this-blog-works/",
      "url": "https://blog.niclaslindstedt.se/posts/2026-04-22-how-this-blog-works/",
      "title": "How this blog works",
      "summary": "A git-tracked blog where I write the prose and a Claude skill pulls my own repos, cites the files, and ships a non-technical version alongside.",
      "content_html": "<p>Since ideas are more interesting than the code, we need a place to store the ideas. The code is already stored by GitHub. This blog stores the ideas behind the code.</p>\n<p>The blog itself is a GitHub repository served by GitHub Pages. It&#x27;s built on React, and the posts are markdown files with YAML frontmatter for metadata<!-- -->&lt;sup&gt;<a href=\"https://github.com/niclaslindstedt/blog/blob/7bc6163c5dd2b48d49a7e4b9285ff40f414ce82f/website/scripts/extract-posts.ts\">1</a>&lt;/sup&gt;<!-- -->. None of that is the interesting part — terminal UIs in the browser were possible ten years ago. What&#x27;s new is that writing a post involves invoking a Claude skill<!-- -->&lt;sup&gt;<a href=\"https://github.com/niclaslindstedt/blog/blob/7bc6163c5dd2b48d49a7e4b9285ff40f414ce82f/.agent/skills/write-post/SKILL.md\">2</a>&lt;/sup&gt;<!-- -->. The skill pulls down whichever of my public repositories are relevant to the post<!-- -->&lt;sup&gt;<a href=\"https://github.com/niclaslindstedt/blog/blob/7bc6163c5dd2b48d49a7e4b9285ff40f414ce82f/.agent/skills/write-post/scripts/clone-repos.sh\">3</a>&lt;/sup&gt;<!-- -->, walks their commit history<!-- -->&lt;sup&gt;<a href=\"https://github.com/niclaslindstedt/blog/blob/7bc6163c5dd2b48d49a7e4b9285ff40f414ce82f/.agent/skills/write-post/scripts/commits-since.sh\">4</a>&lt;/sup&gt;<!-- -->, reads the files the post actually touches, and turns bare project names into links and concrete claims about code into footnotes that open the source in place<!-- -->&lt;sup&gt;<a href=\"https://github.com/niclaslindstedt/blog/blob/7bc6163c5dd2b48d49a7e4b9285ff40f414ce82f/.agent/skills/write-post/STYLE_GUIDE.md\">5</a>&lt;/sup&gt;<!-- -->. I don&#x27;t want Claude inventing facts about my code. Citations force it to open the file first.</p>\n<p>I write the posts. Claude structures them into markdown, adds the links, files them where they belong, and deploys to GitHub Pages<!-- -->&lt;sup&gt;<a href=\"https://github.com/niclaslindstedt/blog/blob/7bc6163c5dd2b48d49a7e4b9285ff40f414ce82f/.github/workflows/pages.yml\">6</a>&lt;/sup&gt;<!-- -->. It also rewrites every post in a non-technical version, and either version can stand alone, because sometimes the tradeoff I care about is different for a dev than for my parents.</p>\n<p>The past year I&#x27;ve been experimenting with spec-driven development and CLIs that lift logic out of prompts and into the tool itself. If the rules live in a prompt they drift. If they live in the binary they don&#x27;t. <a href=\"https://github.com/niclaslindstedt/oss-spec\">oss-spec</a> is what this blog itself conforms to, and I&#x27;ll be writing about that approach along with the other tools I&#x27;ve built around it. They&#x27;re all open source on <a href=\"https://github.com/niclaslindstedt\">my GitHub</a>.</p>\n<p>I don&#x27;t usually blog, but when I do, it&#x27;s mostly a tech demo.</p>",
      "date_published": "2026-04-22T05:51:05Z",
      "date_modified": "2026-05-13T13:17:42Z",
      "tags": [
        "blog",
        "meta",
        "claude-code",
        "spec-driven-development"
      ],
      "authors": [
        {
          "name": "Niclas Lindstedt",
          "url": "https://niclaslindstedt.se"
        }
      ]
    }
  ]
}
