extensible web apps #040

AI Focus

extensible web apps

When I started to write this post it was about how much I am struggling with merge issues now that my agent fleet is working all day and more importantly how merging slows our ability to build software. I was finding that my agents would happily build my tools and products all in just a couple of files, or in ways that would just cause merge conflicts for even the smallest set of changes. However, while I was writing that snoozefest, I was trying to put what I was typing into practice and I think I’ve just rebuilt SharePoint… wait, wait, wait, stay with me… I promise this post will be good. If it’s not, email me or X-flame me.

Stepping back a bit, I’ve been thinking for a long time about how the web might evolve now that we have the tools (LLMs and coding-harnesses) that can build almost anything and we also have the tools that can take almost anything the user needs.

I’ve been noodling with lots of different types of harnesses that wrangle agents: A voice-based one (more soon - but it’s very cool), a TweetDeck-style Chrome harness, another Chrome Agent platform harness and an API that has a CLI and a TUI. I live in a lot of these tools now, they control my machines and help me work in ways that I personally find very useful. They create tools for me and orchestrate a lot of tasks that I do.

I’ve also been using a lot of other Agent Harnesses. Pi is my particular favourite because of its low footprint and its ethos of customization. I liked it so much that I realised that my entire approach with agent-do was entirely wrong. Pi got me thinking about how I might bring the concept of harness customizability to the web.

I built a Markdown editor that I hoped would follow the spirit of Pi. A tiny core that does the bare minimum, while exposing a surface that is entirely customizable to what the user needs.

A markdown editor editing this very post

Look YAME! (Yet another Markdown Editor)

It’s worked surprisingly well. It serves my needs and if I need a new feature, say a little notification that tells me the reading time, well, I don’t have to bake it into the core, I can just create a little plugin outside the main repo and not have to deploy it.

The plugins have access to different parts of the UI.

Extending the UI

Extending the UI

And they can hook into events and actions that happen inside the web app such as pressing keys and then updating the UI.

The blue line is a sentence length plugin

The blue line is a sentence length plugin

For example, I have a sentence length plugin that could be something that is in the core, and so could the writing streak plugin, but why should I have to rebuild and redeploy the project if I get bored of them one day? Do I really need to go back and re-build a project just to add a side-panel that checks all the claims that I am making? No, no, I shouldn’t have to.

The principles that I saw in Pi were incredibly invigorating and I started to see every problem in software as one that could be fixed with an Extensibility-shaped hammer.

Then I remembered the awesome post “Malleable software: Restoring user agency in a world of locked-down apps” by the folks at Ink and Switch (and lo and behold, they also built a Markdown Editor :D) and I really had to dive a lot deeper into this space and try and answer the question: Why don’t more websites offer customization?

So, why don’t more websites offer customization? Easy. It’s hard.

It’s hard to be secure. It’s hard to be performant. It’s hard as a product owner to design a product around customization without feeling like you are losing control, and it’s hard to not break existing customizations when you update your core. While there is user demand (just look at Chrome extensions) I think people want a naked robotic core and then the ability to dress and change their tools to their needs.

Then I discovered the DeepSeek Harness. The harness is built around the principle that everything in the core system is a plugin. It’s rather spectacular. The paper that describes the Cordis system is rather incredible too. While I found the Maths and proofs of theorems overwhelming, if you can drive through it there are glimpses of magic for mere mortals like me: Self-evolving Harness systems.

It left me thinking. If I want the web to win in the long run then we need websites to offer more than what you can get inside a Chat harness. Chat apps will be dynamic in the future and completely personalised to the user, so why don’t we have the same for web applications that live on the web?

So, well, here we are… Introducing my experiment: Extensible Web Apps [source] — a harness that enables you to build and compose web apps that the user can extend how they see fit.

Before I explain anything, have a play. This is a beat pad built on the platform, running live in this page. Press play. Then hit Customize in the corner, scroll down to “Samples for this app” and press Add next to Sunset theme. You’ll be shown its code and what it’s allowed to do (change the app’s colours, and nothing else). Accept it. You just installed a plugin into somebody else’s app, and the author (me) didn’t have to do anything to let you.

A step sequencer. The sequencer, the Euclidean rhythm menu and the ripples visualiser are each their own plugin. Open Beat pad full screen β†—

Before we start, I am presuming that you don’t mind that I built this with my agent with heavy and constant review (you will see Claude and other tools all over this repo). This web-app harness builds off the core kernel of Cordis and then attempts to build on top of the existing primitives of the web such as <slot>, <template>, serviceWorker, Worker, SharedWorker, MessageChannel, @layer, @scope to let you build rich and complex web applications that the user can customize without you having to do anything new.

I also had some core things that I wanted to make sure you could customize:

  • A user should be able to add their own plugins
  • The plugins should be able to save their own data
  • The plugins should themselves be extensible, so they should be able to define hooks that other plugins can listen for
  • The plugins should be able to provide their own UI if they want
  • The developer should be able to provide places in their App UI for extension, while the user should be able to also override it and place the UI where they want

While a user can write the code to customize the web app, the expectation is that people will use an LLM to create the plugins at runtime and as such LLMs are built into the harness as a core principle component (even if it’s not enabled by an app developer)..

It’s a rather complex system when you have to try and look at it all, but the way I would say it is: We have an app and it’s made up of plugins that are managed by “the kernel”. Plugins are the system and they are added by the developer who builds the app and, importantly, by the user.

Architecture of the system

Architecture of the system

Data flow

Data flow

Really, everything is a plugin. That includes the UI shell, storage, sync and the AI agents. Plugins run in their own Worker, can only do what their manifest asks for, and draw their UI as plain HTML. They emit events and can listen to messages that were emitted from other plugins, but they can’t directly talk to any other worker. Because plugins are so isolated, this means that we can “safely” run them without stomping over each other, or over the app they’re running in.

Technically, an app needs no server as it can run entirely in the client, but it can also open in a space that a group of people share, where the same kernel runs on a server too, with plugins of its own.

Given this is a harness and it’s for building apps, I don’t take as severe a hard line about what’s in the core as Pi or Cordis do. It’s more akin to how the DeepSeek Harness builds on Cordis, meaning that I give you a product you can build applications on top of.

One of the nice things about this is that because everything is a Worker, all app updates happen via messaging, which means cross-tab communication is easy. The demo that follows is a little bit more complex because it is technically two apps: a contact manager and a generic calendar, but because they both expose a contract for how they work via hooks, they can listen to changes in the data.

You don’t have to take my word for it. Below are the two real apps, each in its own iframe. Add someone in Contacts with a birthday in the next two weeks and watch it turn up in the Calendar’s “Next two weeks” list. (I punched the air when that just worked, and then I punched it again when it also worked embedded in a blog post.)

If you’re reading this somewhere the iframes don’t show up, here’s what it looks like across two tabs. Watch the calendar update when we delete a contact.

Deleting a contact in one tab and the calendar in another tab updating

Data in two different apps syncing across tabs

Here’s the bit I really want you to try. This is a whole plugin. It reads the contacts and puts a little line under the app’s heading. The Contacts app has no slot for it, so the plugin names its own place in its manifest (header h1, put it after).

export const manifest = {
  id: "headcount",
  name: "Headcount",
  description: "Says how many people you know, under the heading.",
  version: "1.0.0",
  permissions: ["ui", "data:read:contacts"],
  places: { count: { at: "header h1", put: "after" } },
};

export default async function (ctx) {
  const html = ctx.ui.html;
  const draw = async () => {
    const people = await ctx.call("data", "list", "contacts");
    ctx.ui.render("count", "count", html`<small>You know ${people.length} people πŸ‘‹</small>`);
  };
  ctx.on("data:changed:contacts", draw);
  await draw();
}

Copy it, hit Customize in the Contacts demo above, and at the bottom of the Plugins tab open “Add a plugin or an agent from files, code or an address”. Paste it into the box and click Add this code. Nothing runs yet: you get a review card with the code and, in plain words, what it will be able to do (“Draw on this page. Read the contacts records, and hear when they change.”). Accept it, add another person, and the count goes up. That’s the whole loop: an app author ships an app, and a user (or their agent) bends it to what they need, without a fork, a rebuild or a deploy.

The Contacts app with 'You know 3 people' added under its heading by a pasted plugin

A plugin pasted into somebody else’s app

Now don’t write any code at all. I’ll be honest, I just cheated. I handed you 18 lines of JavaScript and asked you to paste them into a box. That’s fine for you and me, but it’s hard enough for developers to think in manifests, permissions and places, and it’s simply not something a normal person is ever going to do. They shouldn’t have to. They should just ask.

So the platform has an agent that is always there. In every app, Customize has a Make a plugin tab, and on it is a button: Write it for me. Try it on the Contacts demo (I’d open it full screen for this one, because you’re about to give it an API key):

  1. Customize, then Make a plugin, then Write it for me. The Plugin maker is itself just a plugin, so you get a review card for it like anything else. Accept it.
  2. It needs a language model to write with, so it offers the ones the site has: Claude, Gemini, OpenAI, or Ollama running on your own machine (no key needed). Pick one. With the hosted ones you’ll be asked for your own API key the first time it writes. The key stays on your device and goes only to that provider; no plugin can ever read it.
  3. Say what you want, in your own words, and press Make it. It suggests a few to start you off, or try “Show how many of my contacts have a birthday this month, at the top”.

The agent looks at the page first (which plugins are on it, what data they own, where there’s room to draw), writes a plugin, checks it against the same rules the kernel will hold it to, and then stops and shows you a review card: here’s the code, and here’s what it will be able to do. Nothing it writes runs until you accept. Don’t like it? Tell it. “Make it a card”, “put it under the birthdays list”, “actually, only count this week”. It knows which plugin you mean and saves it again.

Asking the Plugin maker for a birthday count in the Contacts app, accepting the review card, and the count appearing on the page

Asking for a plugin instead of writing one

No key to hand? Chapter 11 of the homepage, “Let an agent change it”, replays a real recorded run of the builder agent, tool call by tool call, and the last step is real: you get the review card, and if you accept it the plugin it wrote is yours.

The important bit for me is that this agent isn’t a feature of the Contacts app. The author of Contacts did nothing. It’s your agent, and it goes where you go:

  • Use in every app. The model you just added is already yours in every app on the site, not just this one. Next to the Plugin maker in Customize (and any other plugin you’ve added) is a button that says exactly that, Use in every app. Press it and the agent is there in every app you open.
  • It follows you. Sign in and your plugins, your agents, where you moved things and the model you picked follow you to your other devices. Your API keys only follow if you ask, and they’re encrypted on your device first so the site can’t read them.
  • Bring the agent you already have. The same tab has “Share MCP link for this tab”. Paste that into Claude Code, Cursor or any MCP client and the agent you already live in can see and reshape the exact page you have open, with the same review cards. And if your browser has an agent built in, an app can offer it the same tools through WebMCP.

I keep coming back to this. In a chat app the agent is always there and the UI is whatever it makes up for you on the spot. On the web the UI is always there and (if we get this right) so is the agent, ready to bend it for you. I know which one I’d rather use every day.

Writing plugins isn’t the only thing an agent can do in an app. Say you build a web app and you don’t have agent support, but the user wants to integrate Claude somehow? Say you had a Book tracking web app and your user keeps asking you to add Agent support in, but you really don’t know what that means, or how it would help? Well, agents are plugins too, so the user can add their own.

You can add your agent - in this case the Agent is a Librarian

You can add your agent - in this case the Agent is a Librarian

In this demo you can see that I submit the details of “Tools for Thought” (I’m a hypermedia nut) and I don’t have the published date or the name of the Author and you can see the Librarian Agent listening for changes to the data store and then talking to the Bibliographer Agent to then make the changes needed to annotate the data (with permission). You can try the Librarian yourself, you’ll just need to bring your own API key.

How the agents work. Here’s the thing I love about this one: neither agent is code. An agent is a Markdown file. The frontmatter is its manifest (what it’s allowed to do, what wakes it up, and how much it may spend) and the body is its instructions. The kernel turns the file into a plugin, so an agent goes through exactly the same permission gate as any other plugin. That’s also why you get asked before the Librarian changes your books: it doesn’t own the library collection, so every write it wants to make shows up on a review card.

Sequence diagram: you add a book with only a title; the kernel wakes the Librarian agent on data:changed:library; it asks the model, then messages the Bibliographer agent, which may ask you to pick between candidates; the Librarian writes the author and year back, and the kernel shows you a review card before the change lands

What happens when you add a book with just a title

The whole app is one page. Two of the plugins are just Markdown files, linked with rel="agent" instead of rel="plugin", and the model is a plugin too, so you can swap Anthropic for OpenAI, Gemini or anything OpenAI-compatible by changing one line:

<link rel="plugin" href="../reading-list/library.js">
<link rel="plugin" href="activity.js">
<link rel="agent" href="librarian.agent.md">
<link rel="agent" href="bibliographer.agent.md">
<link rel="plugin" href="/std/board.js">
<link rel="plugin" href="/std/llm/anthropic.js">

This is the Librarian, the entire file. It wakes whenever a book changes (on: - hook: data:changed:library), it can read and write the library and send messages to the bibliographer, and nothing else. model: fast asks for whichever fast model the app’s LLM plugin has, and limits caps how often it runs and how many tokens it uses; the kernel counts those, not the agent.

---
id: librarian
name: Librarian
description: Fills in a book's author and first year of publication when they're missing.
model: fast
permissions: [data:read:library, data:write:library, bus:send:bibliographer]
on:
  - hook: data:changed:library
  - command: fill-missing
limits: { runs-per-hour: 30, tokens-per-day: 200000, steps: 8 }
---
You look after a reading list, kept in the `library` collection.

When a book is added or changed and it has a title but no author or no year,
fill in what's missing: the author's usual name, and the year the book was
first published. Write the whole book back with `data_put_library`, keeping
its id and every field it already has, and give a short reason, such as
"Added the author and year of Middlemarch".

If you aren't sure of a book, ask the bibliographer: send it the title with
`bus_send_bibliographer`, waiting for the reply, and use its answer. If it
answers "Unknown", leave the book as it is. Never change a
field that already has a value, and never delete a book.

When asked to `fill-missing`, list the books with `data_list_library` and do
the same for each one that needs it.

The tools it mentions, like data_put_library, aren’t something I wrote for agents. They’re generated from the platform’s services, filtered down to what the manifest allows. If the permission isn’t in the frontmatter, the model never even sees the tool.

The Bibliographer can’t touch the library at all. It has an inbox (- message: true) so other agents can ask it things, and one permission, bus:ask, which lets it put a question to you on the board when it isn’t sure:

---
id: bibliographer
name: Bibliographer
description: Answers questions about who wrote a book and when, asking you when a title could be more than one book.
model: fast
permissions: [bus:ask]
on:
  - message: true
limits: { runs-per-hour: 30, tokens-per-day: 200000, steps: 6 }
---
You know books. Others send you questions about one: usually who wrote it
and the year it was first published.

Answer in one line, as "Author, year", such as "Susanna Clarke, 2020".

If the title could be more than one well-known book, don't guess: ask the
person with `bus_ask`, giving the candidates as choices, such as
"Mary Shelley, 1818" and "Dean Koontz, 1987", and answer with the one they
pick. If you don't know the book at all, say "Unknown".

And because agents are plugins, everything they do is an event any other plugin can listen to. The activity panel in the demo is an ordinary plugin with one permission, hook:listen:agent:*:

ctx.on("agent:wake", ({ agent, trigger }) => add("woke", `${agent}: ${trigger.kind === "command" ? `asked to ${trigger.name}` : trigger.name}`, "accent"));
ctx.on("agent:toolResult", ({ agent, tool, ok }) => add("tool", `${agent} called ${tool}${ok ? "" : ", which failed"}`, ok ? "ok" : "danger"));
ctx.on("agent:budget", ({ agent, limit, used, max }) => add("limit", `${agent} reached its ${limit} limit: ${used} of ${max}`, "warn"));

So if your users want “AI in the app”, you don’t have to design that feature. They can paste a Markdown file like these into the same box in Customize where you pasted the plugin earlier (it takes agents too), and the platform holds it to the same rules as everything else. The Agents guide has the rest, including agents that check other plugins’ writes before they land.

I wanted to know how far the UI could go when every piece of it is a plugin drawing plain HTML from a worker, so I (well, me and my agents) built a bunch of apps. All of them are at extensibleweb.app/demos, and every one has the Customize button.

  • Studio board: a kanban board with due dates, checklists, WIP limits and a “pulse” chart. Try the Focus and Midnight theme samples at the bottom of Customize.

    Open Studio board full screen β†—

    The Studio board kanban app

    Studio board image if it doesn’t work for you

  • Trails: an activity journal with canvas charts and a year heatmap. The Miles sample shows every distance in miles, and the app’s author never wrote a units setting.

    Open Trails full screen β†—

    The Trails activity journal

    Trails

  • Household: a shared shopping list and chores. On its own it works in your browser; open it in a shared space and the kernel also runs on a server, so everyone in the house stays in sync, even after being offline.

  • The plugin debugger: an app that inspects the running kernel: every plugin, what it asked for, and the events going past.

  • The homepage itself: it’s made of plugins too. Every chapter is a plugin, and the switchboard lets you turn them off one by one until there’s nothing left (yes, you can turn off the switchboard; there’s a reset).

    The Extensible Web Apps homepage with every plugin switched off

    The homepage with every plugin turned off

Want to build your own? You need a recent Node (I use 22) and that’s it. There’s no build step for your app: an app is an HTML page and some plugins, each one an ES module.

1. Make a project. It isn’t on npm yet, so you run it straight from GitHub:

npx github:PaulKinlan/ExtensibleWebApps packing-list
cd packing-list
npm run dev

That makes a small project of your own in packing-list/: your app’s three files at the top, and the platform (the kernel, the standard plugins, the service worker) as a dependency in node_modules, not a copy. The first install builds the platform, so give it a minute. Open http://localhost:5173/ and you have a working list app. The folder name becomes your app’s id, so keep it to lowercase letters, digits and dashes.

2. Look at what you got. Three files. The page, app.html, says which plugins make up the app with <link rel="plugin">, and where they can draw with <slot>:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Packing list</title>
  <link rel="stylesheet" href="/platform/host.css">
  <script type="module" src="/platform/host.js"></script>
  <link rel="plugin" href="main.js">
  <link rel="plugin" href="/std/debug-console.js">
</head>
<body>
  <header>
    <h1>Packing list</h1>
  </header>
  <main>
    <slot name="main"><p>Loading…</p></slot>
  </main>
  <slot name="debug" float></slot>
</body>
</html>

The plugin, main.js, starts with a manifest the kernel reads as data before any of its code runs. It owns a collection with a JSON schema (so every write is checked), and it draws a live view, which redraws whenever the data changes, in this tab or any other:

export const manifest = {
  id: "packing-list",
  name: "Packing list",
  description: "A list to start from.",
  version: "0.1.0",
  permissions: ["ui"],
  collections: {
    "packing-list-items": {
      title: "Items",
      schema: {
        type: "object",
        required: ["title"],
        properties: {
          title: { type: "string", title: "Item", maxLength: 200 },
          done: { type: "boolean", title: "Done" },
        },
      },
    },
  },
};

export default async function (ctx) {
  const views = ctx.ui.views("main.html");

  views.live("main", "items", async (ctx) => {
    const items = await ctx.call("data", "list", "packing-list-items");
    return { items };
  });

  ctx.ui.onEvent(async (action, value) => {
    const [verb, id] = action.split(":");
    if (verb === "done") {
      const item = await ctx.call("data", "get", "packing-list-items", id);
      if (item) await ctx.call("data", "put", "packing-list-items", { ...item, done: value === true });
    }
    if (verb === "remove") await ctx.call("data", "delete", "packing-list-items", id);
  });
}

And the view, main.html, is a plain <template>. Holes like {{item.title}} are always filled as text (never HTML), <template for> repeats, <template if> keeps or drops, and data-action sends an action back to the plugin. <ui-form> builds a whole form from the collection’s schema and saves new items by itself:

<template id="items">
  <ul class="items">
    <template for="item of items">
      <li class="item" key="{{item.id}}">
        <label><input type="checkbox" checked="{{item.done}}" data-action="done:{{item.id}}"> {{item.title}}</label>
        <button data-action="remove:{{item.id}}">Remove</button>
      </li>
    </template>
  </ul>
  <ui-form collection="packing-list-items" fields="title" submit="Add"></ui-form>
</template>

(I’ve trimmed the starter a little here. The real files have comments explaining every line, and an empty state.)

3. Add a plugin. Make a new file next to the others, add a <link rel="plugin" href="weather.js"> to app.html, and a <slot name="side"> if it needs somewhere to draw. Or don’t touch the page at all and add it through Customize like you did above, or ask the Plugin maker for it. npm run check finds the usual mistakes before you open the page: a manifest the kernel won’t read, a plugin drawing into a <slot> the page doesn’t have, or a call the plugin’s permissions don’t allow.

4. Or let your agent do it. This is really how I expect most plugins to get written. The repo has an AGENTS.md with the rules the kernel enforces (it’s in your project too, at node_modules/extensible-web-apps/AGENTS.md), and the site has an llms.txt. Point Claude, Pi, or whatever you use at your project and ask for the plugin you want.

5. Ship it. Your app can go out on a Cloudflare Worker of its own, with the platform (including the server side for shared spaces) carried along:

npx wrangler login     # once per machine
npm run deploy

It prints your app’s address, https://packing-list.<your-subdomain>.workers.dev/. npm run deploy -- --dev runs the same Worker on your machine first. An app of your own has the details.

I would never have done this project if I didn’t have an LLM, but it has been incredibly fulfilling to think about an extensible system and how we can use a lot of existing Web primitives to build complex composable web apps. I’ve particularly enjoyed getting back into MessageChannels and MessagePorts (two of the most underrated APIs on the web).

Is this the future of web apps? I don’t know. I think it’s compelling and I think it offers a practical vision for how the web can continue to evolve. But it’s not without its downsides. It’s hard enough for developers to think about how to componentize their projects. Imagine a normal person using your software. To that extent, I really think this will be “agent-led”, i.e., you don’t write the plugin, you ask the agent that’s always there to mold and craft the tools you use, and your job is to read the card and say yes or no. Also, this certainly isn’t complete or production-ready yet; I built it over 4 days with an LLM after all.

For me though, I can’t not explore these concepts, especially as everything around the web is evolving so quickly. The concepts of minimal cores, malleable software and user-extensibility are a very interesting area for the future of software and I want the web to be central.

I even recreated the Markdown Editor mentioned at the start of this post as an extensible set of components using this very platform.

Markdown Editor written as an Extensible Web App

Markdown Editor written as an Extensible Web App

So, anyway, I will get around to writing about merge issues, but for now you have a new way of creating web apps that 100% aren’t SharePoint (I promise). I would love you to have a look and send me feedback on this experiment, and to hear if you are working on something similar.

If you want to learn more, here are some good links to get started:

Core:

Developer Guide:

Add a note

Notes are reviewed before other readers see them.

Subscribe to the Newsletter

Get the latest essays and projects on how AI is changing the medium of the web delivered straight to your inbox.

Latest stats on Claude Commits - 2026-11-01

wat the heck: why ship javascript when nobody reads the code? - 2026-10-30

setting goals - 2026-10-25

how might a modern lighthouse work with large language models? - 2026-07-03

does a url in a prompt steer an llm's output toward its content? - 2026-07-03

please mind the model gap - 2026-06-07

I think i've got it: WebMCP is the new web intents - 2026-06-06

building a claw in the browser - 2026-05-22

shipping a prompt - 2026-05-22

how fast do ai models go stale? - 2026-05-18

are pwas cooked? - 2026-05-17

a business in a box - 2026-05-10

How might a browser be developed? - 2026-05-03

agent-do: my agent loop - 2026-05-01

webmcp is the new web intents ... maybe - 2026-04-27

damn claude, that's a lot of commits - 2026-03-30

the token salary - 2026-03-27

the llm whisperer - 2026-03-08

the prompt is the program - 2026-02-21

If NotebookLM was a web browser - 2026-01-25

the browser is the sandbox - 2026-01-25

the projects behind my ai focus posts - 2026-01-02

hyper content negotiation - 2025-11-27

headless cms was a stopgap - 2025-11-23

dead framework theory - 2025-10-12

intercepting the web with an llm - 2025-09-21

calling apis from the browser shouldn't mean exposing keys - 2025-08-22

hypermedia was always the point - 2025-08-18

web components are packaging for ai-generated ui - 2025-07-16

what happens to the cms when ai can build the site? - 2025-07-05

what token slinging taught me about ai costs - 2025-06-30

on-device ai needs a trust model - 2025-06-12

AI Assisted Web Development - 2025-06-04

embedding is the web's composability superpower - 2025-05-28

ai agents make mashups worth revisiting - 2025-05-24

what falling ai model latency means for the web - 2025-05-22

A link is all you need - 2025-05-17

llms are becoming the new super-apps - 2025-05-12

this transition feels different - 2025-05-09