<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Notes from the work · francl.digital</title>
    <link>https://francl.digital/blog/</link>
    <description>Notes from real engagements — websites, connecting systems, AI. Why I built something the way I did and what I would do differently next time.</description>
    <language>en-US</language>
    <managingEditor>krystof@francl.digital (Kryštof Francl)</managingEditor>
    <atom:link href="https://francl.digital/rss.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Why I built this site</title>
      <link>https://francl.digital/blog/why-this-site/</link>
      <guid isPermaLink="true">https://francl.digital/blog/why-this-site/</guid>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <description>I could have stopped at a LinkedIn profile. Here is why I built my own site on Cloudflare Workers instead.</description>
      <category>meta</category>
      <category>astro</category>
      <category>cloudflare</category>
      <category>portfolio</category>
      <content:encoded><![CDATA[LinkedIn lends me a profile until Microsoft decides otherwise. I do not own the typography, I cannot decide what counts as a blog post and what counts as a CV entry. I am somewhere between a few hundred million people and ads for product management courses.

Your own site is different. You write the rules. Even if almost nobody notices.

## I wanted to try Workers

Edge computing interests me and I wanted to try Cloudflare Workers on something real. Zero cold start, a global network without configuration, you pay per invocation not per uptime. The best way to understand a platform is to build something on it.

It ended up as Astro 6 with the Workers adapter, Tailwind v4 with custom tokens, Biome as the linter, and the whole thing in two languages. Fourteen pages, one deploy command.

## I wanted to practice writing

Analysts write documents every day, but always to a template and for people you already know. Writing for an unknown reader on a topic you picked yourself is a different task. I want to get better at it.

So there will be occasional notes on things I am working through — banking integrations, LLMs in accounting, renewables, the odd tooling thing. No plan, no schedule.

## And I enjoy it

That is probably the main reason. Picking a typeface for the seventh time. Tuning the grid colour until it stops fighting the text. Figuring out why the font behaves differently than it should.

**A hobby.** And a hobby gets a pass on the things that do not quite make sense.

Welcome. If something reads poorly, drop me a line — I will probably agree.]]></content:encoded>
    </item>
    <item>
      <title>Raveo: how I built my own Cloudflare stack for client websites</title>
      <link>https://francl.digital/blog/raveo/</link>
      <guid isPermaLink="true">https://francl.digital/blog/raveo/</guid>
      <pubDate>Thu, 12 Mar 2026 00:00:00 GMT</pubDate>
      <description>Every commissioned website started from zero. So I built myself a foundation — the client manages the content, there is no server to maintain, and the site runs fast worldwide.</description>
      <category>Cloudflare</category>
      <category>Astro</category>
      <category>PayloadCMS</category>
      <category>Workers</category>
      <category>edge</category>
      <content:encoded><![CDATA[The problem with building websites on commission is that you start from scratch every time. Pick a CMS, set up hosting, figure out how to get content to the frontend, sort out caching. Then do the same thing again next time, just slightly differently.

I wanted my own foundation: a monorepo with CMS and frontend, deployable with a single command, with content the client manages themselves. No traditional server. That is how Raveo came together.

For whoever is paying for the site, that comes down to three things: they change the text and photos themselves instead of emailing a developer about a typo, there is no server anyone has to watch and patch, and the bill follows real traffic rather than a machine idling all month. The rest of this note is how it is put together underneath.

## What Raveo is

Raveo is a monorepo with two Cloudflare Workers. One runs `PayloadCMS` on `Next.js` as a headless CMS. The other runs an `Astro` frontend. Everything else is Cloudflare services: `D1` as the database, `R2` for media, `KV` for cache.

```
raveo/
├── apps/
│   ├── cms/     PayloadCMS + Next.js → Cloudflare Worker
│   └── web/     Astro v6 → Cloudflare Worker
└── packages/
    ├── types/   generated types from PayloadCMS schema
    ├── ui/      shared components + Lexical renderer
    └── config/  shared tsconfig + Biome configuration
```

`Turborepo` manages the monorepo with a `pnpm` workspace. Deploying both workers at once takes one command.

## Cloudflare primitives: what each piece does

Before the implementation, a quick explanation of what each service actually is — it is not obvious from the names.

**Workers** are V8 isolates — small isolated environments that run code on each request. They do not start as a traditional server sitting in memory waiting. They spin up on demand, handle the request and disappear. No cold start, global network, you pay per real request not per uptime.

**D1** is serverless SQLite directly on the Cloudflare edge. No PostgreSQL server, no managed database. A SQLite file replicated globally, accessible via a Workers binding. For a CMS database that does not need millions of concurrent writes per second, it is a good fit.

**R2** is object storage compatible with the S3 API. Media files, images, documents. No egress fees for downloads — a meaningful difference from standard S3 when traffic picks up.

**KV** is a distributed key-value store with very low read latency. Good for caching: write once, read many times. With TTL expiration built in.

## PayloadCMS on Workers: D1 and R2 adapters

`PayloadCMS` normally expects a Node.js server and PostgreSQL or MongoDB. Cloudflare Workers are a runtime without Node.js and without TCP access to external databases. Adapters solve this.

In `payload.config.ts` the configuration looks like this:

```ts
export default buildConfig({
  collections: [Users, Media, Categories, Posts, Pages, Forms, FormSubmissions],
  globals: [Navigation, SiteSettings],
  editor: lexicalEditor(),

  // D1 instead of PostgreSQL
  db: sqliteD1Adapter({
    binding: cloudflare.env.D1,
    push: !isSeed,
  }),

  // R2 instead of local filesystem or S3
  plugins: [
    r2Storage({
      bucket: cloudflare.env.R2,
      collections: { media: true },
    }),
  ],
});
```

The `D1` binding is direct database access without a network hop. The `R2` binding is direct object storage access. Everything goes through internal Cloudflare channels, not the public internet.

The result: the `PayloadCMS` admin interface runs as a Worker. The client logs in, manages content, saves changes. Database is `D1`, media goes into `R2`.

## Service Bindings: how the workers talk to each other

Two workers need to communicate. The naive solution is calling the CMS over HTTP, but that adds latency, a DNS lookup and an extra network hop.

Cloudflare has Service Bindings for this. A direct connection between Workers without HTTP, without DNS, with zero latency. Worker A calls Worker B like a function, not an HTTP endpoint.

In the middleware it looks like this:

```ts
async function fetchFromCMS(fetcher: Fetcher | null, cmsUrl: string, path: string) {
  const url = `https://cms${path}`;
  const fallbackUrl = `${cmsUrl}${path}`;

  const res = fetcher
    ? await fetcher.fetch(url)   // production: service binding, zero latency
    : await fetch(fallbackUrl);  // dev: HTTP to localhost:3000

  if (!res.ok) return null;
  return await res.json();
}
```

`fetcher` is the Service Binding — available in production via `env.CMS`. In local development it is not available, so it falls back to HTTP. Switching is automatic.

## Middleware and KV cache

Astro middleware runs on every request. Its job is to load data from the CMS and pass it to pages via `locals`. Calling the CMS on every request would be unnecessarily slow.

So there is a `KV` cache in between:

```ts
async function cachedFetch(fetcher, cmsUrl, path, cache) {
  // 1. Try KV cache
  if (cache) {
    const cached = await cache.get(path, 'json');
    if (cached) return cached;
  }

  // 2. Cache miss — call CMS
  const data = await fetchFromCMS(fetcher, cmsUrl, path);

  // 3. Write to KV for 5 minutes (fire-and-forget, does not block response)
  if (data && cache) {
    cache.put(path, JSON.stringify(data), { expirationTtl: 300 }).catch(() => {});
  }

  return data;
}
```

The key detail is the `fire-and-forget` KV write. The response to the user does not wait for the cache write to complete. The write happens in the background.

On every request four things are fetched in parallel:

```ts
const [navigation, siteSettings, pagesData, postsData] = await Promise.all([
  cachedFetch(fetcher, cmsUrl, '/api/globals/navigation?depth=1', cache),
  cachedFetch(fetcher, cmsUrl, '/api/globals/site-settings?depth=1', cache),
  cachedFetch(fetcher, cmsUrl, '/api/pages?depth=2&limit=100&where[status][equals]=published', cache),
  cachedFetch(fetcher, cmsUrl, '/api/posts?depth=2&limit=100&where[status][equals]=published', cache),
]);
```

`Promise.all` matters here — all four calls go in parallel, not in sequence.

## Content revalidation

The KV cache has a five-minute TTL. But what if the client saves a change and wants to see it immediately?

Every collection in the CMS has an `afterChange` hook. When content is saved, the hook calls the Web Worker and tells it to invalidate the cache:

```ts
const revalidate = async () => {
  if (process.env.NODE_ENV === 'production') {
    // Service binding: CMS Worker calls Web Worker directly
    await cfEnv.WEB.fetch(
      new Request('https://web/api/revalidate', {
        method: 'POST',
        headers: { 'x-revalidate-secret': cfEnv.REVALIDATE_SECRET ?? '' },
      }),
    );
  } else {
    await fetch(`${webUrl}/api/revalidate`, { method: 'POST', ... });
  }
};

export const revalidateAfterChange: CollectionAfterChangeHook = async ({ doc }) => {
  await revalidate();
  return doc;
};
```

After revalidation the KV cache expires and the next request fetches fresh data from the CMS. No rebuild pipeline, no waiting on a deploy.

## Lexical renderer: JSON to HTML without JavaScript

`PayloadCMS` stores rich text in `Lexical JSON` format — a tree of nodes. Ready-made libraries handle this through React and client-side JavaScript. But Astro pages are static and I do not want client-side JavaScript for this. So I wrote a custom server-side renderer.

The interesting part is text formatting. Lexical stores format as a bitmask — a number where each bit represents a different style:

```ts
function renderTextFormat(text: string, format: number): string {
  let result = escapeHtml(text);
  if (format & 16) result = `<code>${result}</code>`;    // inline code
  if (format & 1)  result = `<strong>${result}</strong>`; // bold
  if (format & 2)  result = `<em>${result}</em>`;         // italic
  if (format & 8)  result = `<u>${result}</u>`;           // underline
  if (format & 4)  result = `<s>${result}</s>`;           // strikethrough
  if (format & 32) result = `<sub>${result}</sub>`;       // subscript
  if (format & 64) result = `<sup>${result}</sup>`;       // superscript
}
```

The bitwise AND (`&`) checks whether a given bit is set. Text can be bold and italic at the same time — both bits are set simultaneously. The renderer walks the full tree recursively: paragraphs, headings, blockquotes, lists, checkboxes, links, images. Output is clean HTML with no client-side JavaScript.

## Rate limiting on KV

I did not want to bring in an external service for rate limiting. The `KV` namespace for cache is already there, so I used it for rate limiting too.

The implementation is a sliding window: for each IP address it stores a request count and the start of the current time window:

```ts
interface RateLimitEntry {
  count: number;
  windowStart: number;
}
```

Key design decision: the rate limiter **fails open** — if `KV` is unavailable, the request goes through. The alternative would be to reject the request, but a KV outage would then take down the entire site. Fail open is the right call here.

POST requests are limited to 10 per minute, API endpoints to 30. Responses include RFC-standard `RateLimit-Remaining` and `Retry-After` headers.

## Where it is now and where it is going

I am building the website for my Scout troop Prácheň on Raveo. It will be the first real production deployment of the stack.

Down the road I am interested in integrating `Medusa.js` as a transactional backend. That would make Raveo a base for e-commerce builds on commission: `PayloadCMS` for content management, `Medusa` for products and orders, `Astro` for the frontend. All without a traditional server, globally distributed, with content the client manages themselves.

The full project is open source at [github.com/raveo-dev/raveo](https://github.com/raveo-dev/raveo).]]></content:encoded>
    </item>
    <item>
      <title>An MCP tool that catches errors in integration specs</title>
      <link>https://francl.digital/blog/esmm-validator/</link>
      <guid isPermaLink="true">https://francl.digital/blog/esmm-validator/</guid>
      <pubDate>Thu, 08 Jan 2026 00:00:00 GMT</pubDate>
      <description>A typo in a spec surfaces at the tester or in the developer's code, and that means going back and redoing the work. So I built a tool that checks it earlier — an MCP server for the AI assistant in the editor.</description>
      <category>MCP</category>
      <category>integration</category>
      <category>validation</category>
      <category>AI</category>
      <category>banking</category>
      <content:encoded><![CDATA[Before a bank can connect two systems, somebody has to write down which field in one message corresponds to which field in the other. We call that document an ESMM and it is a spreadsheet. When someone mistypes a line in it, nothing on the page looks wrong: the error turns up either in test analysis or in the developer's code. Either way you go back to the spec and redo it.

It happened regularly and there was no tool that checked the entry beforehand. So I wrote one.

## What ESMM is and where the errors come from

Into the ESMM an analyst writes how data moves between systems: what `xpath` a field has in the input message, what it is called in the target, what conditions apply. From that come the technical specs, unit tests and implementation code.

Example `xpath` for a REST service:

```
createInternalPO//POST/request/BODY/accountNumber/numberPart1
```

And for TIF/WMB:

```
//getListRequest/identity/userLoginName
```

The format is different for each technology. REST has a different structure than TIF, arrays are written differently, slash rules differ. All of it has to match exactly against the `XSD` or `YAML` file structure. A typo, wrong capitalisation, an extra leading slash. None of these errors are visible in Excel until someone else starts processing it.

The rules are clear and mechanical. They can be checked automatically.

## What MCP is and what you can build with it

Before the implementation, a quick word on MCP as a concept, because it gets talked about a lot but rarely explained concretely.

MCP (Model Context Protocol) is a protocol that defines how an AI client (VS Code Copilot, Claude Desktop, ...) communicates with an external server. The server runs locally or somewhere on the network, the client connects, and from that point the AI has access to whatever the server exposes.

A server can offer four types of things:

**Tools** are functions the AI can call. In my case there are six: workspace discovery, temp folder creation, `XLSX` to markdown conversion, orchestration analysis, IMS file lookup, and validation input preparation. The AI gets a list of available tools with descriptions and decides itself when and how to call them.

**Resources** are static data or knowledge the AI can read. Mine are validation rules: a full spec of what valid `xpath` looks like for REST, what for TIF, what counts as a comment, what gets checked and what does not. The AI loads them as context before it starts validating.

**Prompts** are predefined templates that describe how to start working. Instead of the user writing an instruction from scratch, they pick a prompt and the server prepares a structured input. My workflow prompt looks like this:

```
You are an AI agent for complete ESMM validation. Run steps 1–8 sequentially.
Do not skip, do not parallelize.

1. discover_workspace_structure – find the service, save SERVICE_ROOT_PATH.
2. create_temp_validation – create a folder for artifacts.
3. convert_to_md – convert XLSX, head.md must be created.
4. analyze_orchestration_info – read orchestration, find services for IMS lookup.
5. find_ims_service – find files for each service.
6. create_ai_validation_sampling – build sampling_input.md.
7. auto_ai_validation – run sampling, save result.
8. Final report – summarise results or error.
```

The user writes a service name, triggers the prompt and the server takes them through the whole workflow automatically.

**Sampling** is the fourth type and the most interesting one. The server can ask the client through the protocol to call an AI model on its behalf. More on that below.

In `index.js` the initialisation looks like this:

```js
this.server = new Server(
  { name: "esmm-validation-server", version: "1.0.0" },
  {
    capabilities: {
      tools: {},
      resources: {},
      prompts: {},
      sampling: { createMessage: true },
    },
  }
);
```

Four lines, four capability types. The server communicates over `stdio` — the client starts it as a subprocess and everything goes through standard input and output.

## How it works step by step

The first six steps are a preparation pipeline. The server walks the repository structure and finds the service folder. It creates a `temp_validation` folder for intermediate results. It converts `XLSX` files to markdown, each sheet as a separate table. From the header sheet it reads which services are orchestrated and what technology they use. For each service it finds the corresponding files.

File lookup was an interesting problem because repository structures are not always consistent. I implemented multi-stage searching: first an exact match by folder name, then pattern matching by system prefix, then a recursive search of the whole folder. Only then does it say nothing was found.

After finding all files it builds `sampling_input.md`: one large markdown document with all data combined. ESMM tables, `XSD` structures, `YAML` definitions.

## MCP Sampling in practice

The seventh step is the key one. Instead of calling an AI API directly I used Sampling: the server asks the client to call the model on its behalf.

Why do it this way? The client (Copilot) handles authentication, rate limiting and model selection itself. The server does not need to deal with any of that. The model also sees the full working context in the client, not just an isolated API request.

Large ESMM files had to be split into blocks, otherwise they would not fit in the token limit:

```js
const MAX_BLOCK = 40000;
const blocks = [];
for (let i = 0; i < samplingContent.length; i += MAX_BLOCK) {
  blocks.push(samplingContent.substring(i, i + MAX_BLOCK));
}

const messages = blocks.map((block, idx) => ({
  role: "user",
  content: {
    type: "text",
    text: idx === 0 ? block : `# CONTINUATION\n${block}`,
  },
}));

const samplingResponse = await server.createMessage({
  messages,
  systemPrompt,
  maxTokens: 16000,
  modelPreferences: { intelligencePriority: 0.9 },
});
```

Each block is a separate message, the second and later ones have a `# CONTINUATION` header so the AI knows it is getting sequential data. The system prompt defines the exact validation rules.

The output is a markdown table:

```
| index | file | sheet | row | column | raw_path | error_code | expected |
```

If the AI returns something unusable or an empty response, the tool discards the result and returns an empty skeleton. Bad results do not propagate further.

## What does not work and what I am fixing

It would not be fair to only write about what works.

The biggest practical problem is the context window. With a larger number of files or more complex `XSD` structures the context gets exhausted before the AI finishes validating. Sampling breaks off or returns an incomplete result. I handle it by splitting into smaller blocks, but with really large ESMM files it still hits the limit.

Second issue: eight sequential steps the user has to trigger manually is inconvenient in practice. I built a simpler three-step workflow that compresses the whole process and the user triggers it once. Same result, much less friction.

Third thing, a positive discovery this time: the model matters a lot. `claude-sonnet-4-6` with Thinking mode handles things where other models get stuck. Specifically: when the tool cannot find a file due to a naming inconsistency, the model identifies where the problem is, fills in the missing input and continues into the sampling phase without the user having to step in. That is a difference that saves real time.

## Where it is now

Colleagues in the integration department are starting to use it. They run the validation before the ESMM goes to test analysis, and errors that used to surface at the tester or the developer get caught there instead.

The project is internal and still evolving. But the principle transfers: take a spreadsheet spec, compare it against the actual files, return what does not match. MCP gives that a clean structure: tools for actions, resources for knowledge, prompts for workflow, sampling for AI. Four building blocks and from them you can put together something that behaves like a proper participant in the development environment, not an isolated script.]]></content:encoded>
    </item>
  </channel>
</rss>
