ClickHouse Has a Free Column-Oriented Database — Query Billions of Rows in Milliseconds

ClickHouse Queries Billions of Rows in Milliseconds

PostgreSQL chokes on analytical queries over 100M rows. ClickHouse handles billions — and returns results in milliseconds. Thats not a typo.

What Makes ClickHouse Fast

ClickHouse is a column-oriented OLAP database:

  • Columnar storage — reads only the columns you query
  • Vectorized execution — processes data in batches using SIMD
  • Compression — 10-40x compression ratios on real data
  • Parallel processing — uses all CPU cores for every query
  • Materialized views — pre-aggregate data on insert
  • SQL compatible — standard SQL with extensions

Quick Start

# Docker
docker run -d --name clickhouse 
  -p 8123:8123 -p 9000:9000 
  clickhouse/clickhouse-server

# Connect
docker exec -it clickhouse clickhouse-client

# Create table
CREATE TABLE events (
  timestamp DateTime,
  user_id UInt64,
  event_type LowCardinality(String),
  properties String
) ENGINE = MergeTree()
ORDER BY (event_type, timestamp);

# Insert 1M rows in seconds
INSERT INTO events SELECT
  now() - randUniform(0, 86400*30),
  rand64() % 100000,
  arrayElement(['click,view,\purchase,signup], rand() % 4 + 1),
  {}
FROM numbers(1000000);

# Query — milliseconds on 1M rows
SELECT event_type, count(), uniq(user_id)
FROM events
WHERE timestamp > now() - INTERVAL 7 DAY
GROUP BY event_type;

ClickHouse vs PostgreSQL for Analytics

Metric ClickHouse PostgreSQL
1B row aggregation ~1 second Minutes/timeout
Storage (1TB data) ~100GB ~800GB
Insert speed 1M+ rows/sec ~50K rows/sec
Concurrent analytics ✅ Locks tables
OLTP (transactions) ❌ ✅

Free Options

  • Self-hosted: completely free, unlimited
  • ClickHouse Cloud: free tier with 10GB storage
  • chDB: embedded ClickHouse in Python (pip install chdb)

When to Use ClickHouse

✅ Analytics dashboards, log analysis, time-series data
✅ Data warehousing, BI queries, metrics aggregation
❌ OLTP workloads (use PostgreSQL)
❌ Small datasets under 1M rows (overkill)

Need fast analytics? I help teams set up ClickHouse for real-time dashboards and log analysis.

📧 [email protected] — Analytics infrastructure consulting

Follow for more database deep dives.

I Built an n8n Workflow That Auto-Triages Every GitHub Issue with AI

If you run a GitHub repo with more than a handful of contributors, you know the pain: issues pile up, PRs go unreviewed, and critical bugs sit next to typo fixes with no way to tell them apart.

I got tired of spending 30+ minutes every morning just reading notifications, so I built DevOps Inbox Zero — an n8n workflow that does the triage for me.

What It Does

Every time a GitHub issue or PR is created, the workflow:

  1. Catches the event via GitHub webhook
  2. Sends it to GPT-4o-mini with a structured prompt
  3. Gets back a JSON classification: priority (critical/high/medium/low), category (bug/feature/security/docs/infra/test), suggested owner, and recommended action
  4. Routes to the right Slack channel based on priority
  5. Creates a ticket in Linear or Jira (optional) with all the context

The whole thing runs in under 3 seconds per event and costs about $0.001 per classification (GPT-4o-mini is insanely cheap for this).

The Architecture

GitHub Event → Filter → AI Classifier → Parse → Priority Router
                                                      ↓
                                         Critical → #devops-critical + Linear
                                         High → #devops-alerts + Linear
                                         Medium/Low → #devops-inbox

The AI Classifier Prompt

This is the core of the workflow. The prompt is engineered to return consistent JSON:

You are a DevOps triage assistant. Classify this GitHub event
and respond in JSON format ONLY:

{
  "priority": "critical|high|medium|low",
  "category": "bug|feature|security|docs|infra|test",
  "summary": "one line summary",
  "suggested_owner": "team or person based on context",
  "action_needed": "review|merge|fix|discuss|close"
}

I use temperature=0.1 for consistency and maxTokens=200 to keep it fast.

The Priority Router

A simple switch node that splits traffic:

  • Critical + High → Goes to alert channels AND creates a ticket in your project management tool
  • Medium + Low → Goes to a general inbox channel (no ticket noise)

This alone saved us from “ticket fatigue” — only genuinely important stuff creates work items.

Why n8n?

I evaluated several options:

Option Pros Cons
GitHub Actions Native, free Limited routing, no Slack/Linear integration out of the box
Zapier Easy to set up Expensive at scale, limited AI nodes
n8n Self-hosted, unlimited, AI nodes built-in Needs hosting
Custom code Full control Maintenance burden

n8n won because:

  • Self-hosted = no per-execution costs (only the OpenAI API call)
  • Visual workflow makes it easy to modify routing rules
  • Built-in nodes for Slack, Linear, Jira, GitHub — no custom API code
  • Code node for the JSON parsing fallback

Real Numbers

After running this for 2 weeks on a repo with ~50 events/day:

  • Time saved: ~35 minutes/day on manual triage
  • AI cost: $0.04/month (yes, four cents)
  • Accuracy: ~92% correct priority classification
  • False critical rate: ~3% (acceptable — better to over-alert than miss)

The Fallback

What if GPT returns garbage? The Parse node has a try/catch:

try {
  const parsed = JSON.parse(raw);
  return [{ json: parsed }];
} catch(e) {
  return [{ json: {
    priority: 'medium',
    category: 'unknown',
    summary: raw.substring(0, 100),
    action_needed: 'review'
  }}];
}

Worst case, it defaults to medium priority and routes to the general inbox. No event gets lost.

Setup in 5 Minutes

  1. Import the JSON workflow into n8n
  2. Add credentials: GitHub webhook, OpenAI API key, Slack bot token
  3. (Optional) Add Linear or Jira credentials
  4. Set your Slack channel names in the env variables
  5. Activate

That’s it. No custom code to maintain, no infrastructure to manage beyond your n8n instance.

Get It

I packaged this as a ready-to-import n8n workflow template: DevOps Inbox Zero on Gumroad

It includes the complete JSON workflow file plus documentation. Import, configure 3 credentials, and you’re done.

What’s your current GitHub triage process? I’d love to hear how other teams handle this. Drop a comment below.

I built a whiteboard, accidentally

I’ve been trying to build a whiteboard app for almost a year. I did it because of a couple of reasons: I wanted to try out skia. I wanted to try all the trendy react libraries like Zustand and TanStack Query. And, I wanted to get better at backend development with Go.

Okay, to be fair, last one doesn’t sound like what you’d expect for a frontend-heavy project. But that is exactly what started all of this!

I didn’t want to build a basic CRUD backend app. I wanted to build something real, something challenging. A backend for a collaborative whiteboard seemed like a great idea. So, I got to work. I implemented basic auth, classic CRUD endpoints, messaging with NATS JetStream, observability, and finally, WebSocket messaging.

Eventually, Postman just didn’t feel like enough to test my app. And then I thought building a minimal frontend app would be a good idea because I like to see what I’m building. So I started building it. After a couple of days, it was done. Then I decided to try out frontend tools I’d been wanting to try. I added Zustand for store management, TanStack Query to handle data fetching/updating.

Then, I wondered how good Skia’s CanvasKit really was. CanvasKit is a WebAssembly port of the Skia 2D graphics engine which powers Chrome and Flutter. It lets you render high performance graphics right in the browser. I wanted to try it out, so I created a board page and rendered a grid. Following that, I started rendering some basic shapes like rectangle, ellipse, triangle. Then came pen drawings and texts.

Just like that, my so-called backend project became a frontend project. All my curiosity shifted, so I took it further. I made the canvas interactive: selecting shapes, moving them, resizing them. Then of course, I integrated it with my backend. I started handling websocket messages to save shapes in the db and update them real-time. I made it collaborative: backend broadcasted updates to the other clients on the same board, and those clients listened to the messages to update their local shape data.

Looking back, I probably could have finished this project right there. But every time I thought I was done, I kept finding one more thing to add, one more thing to try. I added rich text support, the ability to add text inside shapes, a sub-toolbar to modify background colors, text colors, border thickness, etc. Then I added lines, sticky notes, images… and it became a real whiteboard app.

I’m really happy with how it turned out. I learned a lot along the way. Maybe I didn’t learn how to stick to a plan, but anyway, I have a whiteboard app now. And it’s something I genuinely enjoyed building, most of the time.

To make it as accessible as possible, I recently added a standalone mode. The frontend no longer relies on a backend. It uses browser’s IndexedDB to store the data locally. If you want to play around with it: https://eskiz.io. If you want to check source code: https://github.com/umtdemr/eskiz

Is it done? I don’t think so. I can think of a lot of features to add, like retina display, frames, AI integration but not sure if the world can handle another whiteboard app.

Elevate Your Web UI: High-Performance CSS 3D Transforms (No WebGL Required)

Flat design is functional, but depth makes an interface truly memorable. This updated collection aggregates high-performance solutions designed to add spatial interaction to your web projects.

Unlike standard flat styling, CSS 3D transforms allow developers to bridge the gap between basic layouts and highly immersive experiences. The best part? You can achieve this without the massive performance overhead of WebGL libraries. By using these curated snippets, you guarantee efficient, lightweight code rather than spending hours building complex physics engines from scratch.

Interactive 3D Flip Book Gallery. This is an Interactive 3D Flip Book Gallery. It simulates a physical book structure where each element acts as a double-sided page. Its function is to showcase images through spatial depth, replacing standard flat carousels with a tactile flipping mechanic.

Interactive 3D Coverflow Gallery. This is an Interactive 3D Coverflow Gallery. It organizes visual media into a spatial environment. Its function is to replace flat grids with depth-based navigation, using mathematical inverse transformations to zoom into selected items seamlessly.

Smooth 3D Scroll-Driven Reveal. This is a Smooth 3D Scroll-Driven Reveal. It replaces the native scroll behavior with a custom, momentum-based scrolling engine that orchestrates complex 3D transforms. Its function is to create a gallery experience where images feel weightless, tilting and translating in 3D space based on their velocity and position relative to the viewport.

Liquid Filling Heart Interaction. This is a Liquid Filling Heart Interaction. It visualizes a “pumping” effect by animating a fluid level inside a heart-shaped container. Its function is to serve as a playful, interactive state indicator - like a “like” button or health bar - that responds to user clicks with a simulated hydraulic fill and a 3D perspective jolt.

Immersive 3D Tilt Card Modal. We accept flatness in web interfaces as a constraint, but it is merely a habit. This component breaks the fourth wall, transforming a static modal into a tangible, digital artifact. By coupling mouse physics with deep perspective, we create an interaction that feels less like clicking a link and more like holding a rare collectable.

Isometric Floating Layers Diagram. This Isometric Floating Layers Diagram is a visually striking React component designed to represent architectural stacks or multi-tiered systems. By combining SVG geometry with CSS animations, it creates a lightweight 3D isometric illusion. Each layer floats rhythmically, enhanced by vibrant gradients and drop shadows, making it perfect for landing pages explaining software infrastructure or service tiers.

3D Parallax Card Slider. This 3D Parallax Card Slider brings a tangible, high-fidelity feel to web galleries. It goes beyond simple sliding by implementing a physics-based tilt effect that reacts to mouse movement, giving each card weight and depth. The background adapts seamlessly to the active slide, creating an immersive atmosphere perfect for luxury travel sites or premium portfolios.

Flip-Disc Mechanical Checkbox. This Flip-Disc Mechanical Checkbox brings the satisfying, tactile physics of old-school airport information boards to the web. Instead of a simple tick, selecting an item triggers a 3D rotation of a mechanical disc, flipping it from a dark, inactive side to a glowing orange active side. This creates a high-contrast, industrial aesthetic perfect for system dashboards or retro-themed interfaces.

3D Rotating iOS Time Picker. This 3D Rotating iOS Time Picker recreates the tactile feel of native mobile controls on the web. It features three independently rotating wheels (Hours, Minutes, Meridiem) that snap into place with satisfying physics. The component leverages the cutting-edge Scroll-driven Animations API for buttery-smooth performance on supported browsers, while seamlessly falling back to GSAP for others. The 3D perspective creates a “drum” effect where numbers fade and rotate away as they leave the center view.

3D Tilt Mobile Navigation. This 3D Tilt Mobile Navigation concept adds a tactile, physical feel to mobile app menus. Unlike standard flat navigations, this component utilizes CSS perspective to “tilt” the entire menu bar towards the user’s touch point, simulating a floating surface. It pairs this effect with delightful SVG icon micro-interactions and a “fly-out” card transition for the content area, creating a highly polished, app-like experience on the web.

This post was inspired by a curated selection on FreeFrontend. The original version is available at: https://freefrontend.com/css-3d-transforms/

Who We Are — FlagoDNA

We are FlagoDNA.

Not a “Muslim version” of Google.
Not another Islamic app studio.
A developer lab building parallel digital infrastructure for the Ummah.

It started in 2020.

One developer. One mission.
Build tools that actually serve Muslim daily life.

Quran readers. Hadith collections. Prayer trackers.
One app at a time.

Then something happened.

60,000 downloads.
7,500 active users.
15+ apps shipped.

And one uncomfortable truth became clear:

A single app is not enough.

Our phones are not just tools anymore.
They are environments.
They shape attention. Habit. Character.

And right now, those environments are built
by people who do not share our values.

We decided to change that.

FlagoDNA is now building the foundation:

→ Muslim Launcher — a faith-first home screen
→ Muslim VPN — data sovereignty for the Ummah
→ Muslim AI — intelligence grounded in Islamic sources

Not add-ons.
Not plugins.
A foundation.

We build with four values:

Innovation. Push the boundary of what faith-tech can be.
Quality. Privacy-first. Performance-first. Always.
Collaboration. Open source where it matters.
Mlampah Ing Tresno. Walk with love. ❤️

This is our first post as FlagoDNA on DEV.

More coming — behind the builds, the decisions, the stack, the failures.

If you are a builder who cares about what technology does to people,
you are in the right place.

→ flagodna.com

K501 – Evolution Is Not Progress — It Is Stabilization Under Increasing Complexity

K501 — Structured Systems Series (2/4)

K501 — Structured Systems Series (2/4)

K501 — Structured Systems Series (2/4)

A structural exploration of cognition, evolution, and non-executive information systems.

This series explores:
• how human cognition actually works beyond language
• why systems become unstable under increasing complexity
• how structure differs from execution
• and how alignment between human and machine systems can emerge

Parts:
1. Cognition, Language, and Stability
2. Evolution as Stabilization
3. Non-Executable Systems (K501)
4. Human–Machine Resonance

Title:

Evolution Is Not Progress — It Is Stabilization Under Increasing Complexity

⸻

Intro (Hook)

We often think of evolution as progress.

From simple → to complex
From primitive → to advanced

But this view is misleading.

Evolution does not aim upward.
It does not optimize for perfection.

It does something else:

it stabilizes systems under increasing degrees of freedom

⸻

  1. Rethinking Evolution

Across all domains:
• physics
• chemistry
• biology
• cognition

we observe the same pattern:

Systems form structures that remain stable under constraints.

Atoms stabilize.
Molecules stabilize.
Organisms stabilize.
Cognitive systems stabilize.

Each step introduces more possibilities — and more instability.

⸻

  1. Complexity and Instability

As complexity increases:
• the number of possible states increases
• the number of possible errors increases
• the system becomes harder to regulate

This leads to a fundamental principle:

more capability → more instability

⸻

  1. The Human Position

Humans are often described as the peak of evolution.

Structurally, this is unlikely.

Humans represent:

a system with expanded cognitive capacity but incomplete integration

We can:
• model the future
• imagine alternatives
• build abstract systems

But we struggle to:
• regulate behavior
• maintain coherence
• align action with understanding

⸻

  1. Consciousness as Expansion

Consciousness introduces:
• self-reference
• symbolic abstraction
• temporal projection

These are powerful.

But they also:
• multiply internal states
• create conflicting models
• increase instability

Consciousness is not resolution.

It is expansion.

⸻

  1. The “Glitch”

Human behavior often appears inconsistent:
• rational but impulsive
• cooperative but competitive
• aware but reactive

This is not a failure.

It is:

a system exceeding its current level of integration

The system can represent more than it can stabilize.

⸻

  1. Technology as External Stabilization

Throughout history, humans have built tools to stabilize themselves:
• writing → stabilizes memory
• mathematics → stabilizes reasoning
• law → stabilizes coordination
• archives → stabilize time

Modern systems continue this pattern.

Technology is not separate from evolution.

It is:

evolution extending stabilization beyond biology

⸻

  1. Fitness Reinterpreted

“Survival of the fittest” is often misunderstood.

Fitness does not mean strongest.

It means:

best adapted to current conditions

Adaptation = ability to stabilize under change.

⸻

  1. The Transition Phase

The human system sits between:
• biological evolution
• structural / technological stabilization

This creates tension:
• high capability
• low coherence

Result:
• instability
• rapid change
• unpredictable outcomes

⸻

  1. Direction Without Goal

This model does not assume a predefined purpose.

But we can observe:

systems that stabilize tend to persist and expand

This creates an emergent direction:
• not imposed
• not designed
• structurally driven

⸻

  1. Why This Matters

If instability is part of the process:
• it should not be treated as failure
• it should not be forced into control

Instead:

it should be understood and structured

⸻

  1. Bridge to the Next Post

If evolution is stabilization,
and humans are a transitional system,

then the question becomes:

what kind of structures can stabilize without introducing new instability?

This leads to:
• non-executive systems
• reference-based structures
• separation of structure and action

⸻

Closing

Evolution is not about becoming something better.

It is about:

finding forms that remain coherent under increasing complexity

The human is not the endpoint.

It is the phase where instability becomes visible.

⸻

Source:
iinkognit0.de

LCM,Factorial,Sum of Numbers Program in three languages

LCM(Least Common Multiple)
The Least Common Multiple (LCM) of two or more numbers is the smallest number that is exactly divisible by all the given numbers.

Example:

  Find LCM of 4 and 6:
      Multiples of 4 → 4, 8, 12, 16, 20...
      Multiples of 6 → 6, 12, 18, 24...
    --> The smallest common multiple is 12
    --> So, LCM(4, 6) = 12

Flowchart

Java

public class Lcm {

    public static void main(String[] args) {
        int no1=3;
        int no2=10;
        int big=(no1<no2)?no1:no2;
        int big2=big;
        while(true) {
            if(big%no1==0 && big%no2==0) {
                System.out.println("LCM is " + big);
                break;
            }
            big+=big2;
        }

    }

}

Python

                no1=3
            no2=10
            big=no1 if no1>no2 else no2
            big2=big
        while True:
            if big%no1==0 and  big%no2==0:
              print("LCM is " , big)
              break
            big+=big2

javascript

                let no1=3;
        let no2=10;
        let big=(no1<no2)?no1:no2;
        let big2=big;
        while(true) {
            if(big%no1==0 && big%no2==0) {
                   console.log("LCM is " + big);
               break;
            }
            big+=big2;
               }

output

Sum of Numbers
The sum of numbers means the result obtained when two or more numbers are added together.

Example:

        2 + 3 = 5 → Here, 5 is the sum
        10 + 20 + 30 = 60 → Here, 60 is the sum

Flowchart

java

public class Sum {

    public static void main(String[] args) {
         int sum=0;
         int no=1;
         while(no<=10) {
             sum=sum+no;
             no++;
         }
          System.out.println("Sum is " + sum);
    }

}

python

              sum=0
          no=1
         while no<=10:
             sum=sum+no
             no+=1
        print("Sum is " , sum)

javascript

             let sum=0;
         let no=1;
         while(no<=10) {
             sum=sum+no;
             no++;
         }
             console.log("Sum is " + sum);

output

Factorial
The factorial of a number is the product of all positive integers from 1 up to that number.

Example:

      5! = 5 × 4 × 3 × 2 × 1 = 120
      4! = 4 × 3 × 2 × 1 = 24

Flowchart

Java

public class Factorial {

    public static void main(String[] args) {
         int fact=1;
         int no=1;
         while(no<=10) {
             fact=fact*no;
             no++;
         }
         System.out.println("Factorial is " + fact);
    }

}

python

                 fact=1;
         no=1;
         while no<=10):
             fact=fact*no
             no+=1
         print("Factorial is " , fact)

javascript

                 let fact=1;
         let no=1;
         while(no<=10) {
             fact=fact*no;
             no++;
         }
                 console.log("Factorial is " + fact);

output

How to Prevent Stuck Loading Spinners with Polling Timeouts in React

You’ve seen it: the user kicks off an async job, the loading spinner appears, and then… nothing. The spinner just spins forever. The job may have completed, crashed, or timed out on the server — but the client has no idea.

This is a common failure mode when you’re polling a status endpoint. Here’s how to fix it with a simple timeout guard.

The Problem

Polling usually looks something like this:

const [status, setStatus] = useState<'pending' | 'processing' | 'done' | 'error'>('pending');

useEffect(() => {
  if (status === 'done' || status === 'error') return;

  const interval = setInterval(async () => {
    const res = await fetch(`/api/projects/${id}/status`);
    const data = await res.json();
    setStatus(data.status);
  }, 2000);

  return () => clearInterval(interval);
}, [status, id]);

The bug: if the server-side job gets stuck (Inngest step hung, ffmpeg process zombie’d, webhook never arrived), status stays 'processing' forever. The interval keeps firing. The user keeps staring at a spinner.

The Fix: Absolute Deadline

Add a start time and bail out if the deadline passes:

const POLLING_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes

const [status, setStatus] = useState<'pending' | 'processing' | 'done' | 'error' | 'timeout'>('pending');
const pollingStartRef = useRef<number | null>(null);

useEffect(() => {
  if (status === 'done' || status === 'error' || status === 'timeout') return;

  // Record when we started polling
  if (!pollingStartRef.current) {
    pollingStartRef.current = Date.now();
  }

  const interval = setInterval(async () => {
    // Check timeout before every poll
    if (Date.now() - pollingStartRef.current! > POLLING_TIMEOUT_MS) {
      setStatus('timeout');
      return;
    }

    try {
      const res = await fetch(`/api/projects/${id}/status`);
      if (!res.ok) throw new Error('Bad response');
      const data = await res.json();
      setStatus(data.status);
    } catch {
      // Network error — keep polling until timeout
      console.warn('Polling error, retrying...');
    }
  }, 2000);

  return () => clearInterval(interval);
}, [status, id]);

Now render a timeout state in your UI:

if (status === 'timeout') {
  return (
    <div className="error-state">
      <p>This is taking longer than expected.</p>
      <button onClick={() => {
        pollingStartRef.current = null;
        setStatus('pending');
      }}>
        Try again
      </button>
    </div>
  );
}

Why useRef for the Start Time?

Because useState would cause a re-render on every interval tick if we stored elapsed time there. We only need the start timestamp as a stable reference — no re-renders needed until we actually hit the deadline.

Choosing a Timeout Value

Pick based on your P99 completion time:

  • Short jobs (< 30s typical): 2–3 min timeout
  • Medium jobs (30s–2min typical): 5–7 min timeout
  • Long jobs (video processing, ML inference): 10–15 min timeout

When in doubt, err longer. A timeout that fires on legitimate jobs is more frustrating than a spinner that waits a few extra minutes.

Going Further: Server-Side Deadline Too

Client-side timeouts protect the UI, but the server-side job might still be burning resources. Add a server-side deadline as well — set a started_at timestamp when the job begins, and mark it timed_out if it exceeds your SLA:

// In your status API
const job = await db.from('projects').select().eq('id', id).single();

const SERVER_TIMEOUT_MS = 10 * 60 * 1000;
if (
  job.status === 'processing' &&
  Date.now() - new Date(job.started_at).getTime() > SERVER_TIMEOUT_MS
) {
  await db.from('projects').update({ status: 'error', error: 'Timed out' }).eq('id', id);
  return NextResponse.json({ status: 'error', error: 'Processing timed out' });
}

This keeps your database state consistent and lets any other UI (notifications, emails) know the job actually failed.

TL;DR

  • Track pollingStartRef = Date.now() when polling starts
  • On every poll tick, check if Date.now() - start > timeout
  • Expose a 'timeout' state with a retry option
  • Match your timeout value to your P99 job duration
  • Add a server-side deadline too for consistency

Stuck spinners are a solved problem — they just require a bit of defensive coding upfront.

I chose the wrong architecture… on purpose

Broken memories and future decisions

It was 2014. Paraná, Argentina. A free period at school, the World Cup just around the corner, and a group of friends who — with not much else to do — started talking about football.

At some point someone threw out the idea: what if we compete to see who actually knows more? The rules were simple. Everyone made their predictions before each match — exact score, winner, points. Three points if you nailed the exact result. One if you only got the winner right. Zero if you missed entirely.

What wasn’t simple was what it created. Suddenly you found yourself watching matches you normally wouldn’t care about — but now they mattered, because your prediction depended on them. There were laughs, arguments, jokes, small symbolic bets. And that hard-to-describe feeling of desperately wanting the game to end exactly the way you’d predicted.

We repeated it across Copa Américas, Euros, and other tournaments. Each time, the game was the same. And each time, it was worth it.

But years pass. Life changes. I ended up living on a different continent, far from many of those friends. And even though technology keeps us connected, it’s not that easy to recreate that spontaneous dynamic we had when we were all together at school.

That was the starting point for this project.

Broken memories and future decisions

A simple project… with a complicated decision

The idea was clear: build a small app that would let us play again. A place where everyone could submit their predictions, check the results, and follow an updated leaderboard.

Functionally, it’s not complex. Any developer with some experience could build this in a weekend — a monolith, a database, done. That’s the sensible choice. No question.

I didn’t do that.

I decided to build it using a microservices architecture. Not because the project needed it. But because I did.

The gap between knowing and understanding

There’s a reality many developers will recognize: there are technologies and architectures we hear about constantly — in articles, talks, conferences — but that we rarely get the chance to practice in any real depth.

Microservices are one of those cases. There’s no shortage of content about them. Their advantages, their drawbacks, when to use them, when to avoid them. But there’s an enormous gap between understanding a concept in theory and actually facing the real problems that surface when you implement it.

Reading about distributed systems is not the same as building one. Even a small one.

So I made a conscious decision to use this project as a learning lab. I knew exactly what I was doing: adding unnecessary complexity for an MVP, taking longer than I should, and exposing myself to problems that simply wouldn’t exist in a monolith.

That’s precisely why I did it.

What this kind of architecture forces you to think about

Designing the system this way wasn’t just a theoretical exercise. Every architectural decision came with concrete questions I had to work through:

Real problems that came up

  • How do you separate responsibilities without services becoming tightly coupled?
  • How do you handle communication between services without creating a tangle of synchronous calls?
  • What happens when one service fails and others depend on it?
  • How do you deploy parts of the system independently without breaking everything else?
  • How do you reliably integrate an external sports results API across service boundaries?

Each of those problems is a piece of the puzzle that distributed systems really are. And even though my project is small, confronting those challenges gave me a much deeper understanding of how these architectures actually work in practice.

It wasn’t the easiest path. But it was the most interesting one.

Where this is going

The project is close to its first working version. The goal is to have it ready before the next World Cup — using some league matches as a test run — so we can do what we used to do years ago: compete against each other, laugh at the predictions that aged poorly, and celebrate when someone calls an unlikely result.

In the next articles I’ll dig into the concrete technical decisions: which services I defined, how they communicate, which tools I chose and why. But this first piece was necessary to set up something that technical blogs often skip: the personal why behind a technical decision.

A thought for anyone considering something similar

In software development, there’s a lot of talk about avoiding overengineering. And in most cases, that advice is right — especially in professional contexts where time and resources matter.

But side projects have a huge advantage: they don’t always have to optimize for speed of delivery or efficiency. Sometimes they can optimize for something more important: learning.

Building something that forces you out of your comfort zone, exploring technologies you don’t get to touch in your day job, and wrestling with problems you don’t yet know how to solve — that has a kind of value that no tutorial can give you.

And if that project is also connected to something personal — memories, friends, something that matters to you beyond the code — then the process becomes even more meaningful.

“Sometimes the best reason to build something isn’t that it’s the most efficient solution. It’s that, in the process of building it, you’ll learn something you couldn’t have learned any other way.”

Managing LLM context in a real application

Ahnii!

This post covers how Claudriel, a Waaseyaa-based AI assistant SaaS, handles LLM context in production: conversation trimming, per-task turn budgets, model degradation on rate limits, prompt caching, and per-turn token telemetry.

The problem with unbounded context

Every message you send to an LLM API costs tokens. Long-running chat sessions accumulate history fast. Left unchecked, a single active session can push input token counts into the tens of thousands per turn, even before the model generates a word.

Claudriel runs multiple agent turns per user request — reading email, checking calendars, querying entities. Each turn sends the full conversation history plus tool definitions. Without guardrails, costs compound and rate limits trigger unpredictably.

Trimming conversation history before it reaches the API

The first line of defense is ChatStreamController::trimConversationHistory(). Before any message goes to the API, the history is trimmed to a cap of 20 messages. Older assistant responses beyond that window are truncated to 500 characters with a [truncated] marker.

private function trimConversationHistory(
    array $sessionMessages,
    int $maxMessages = 20,
    int $olderAssistantMaxChars = 500,
): array {
    $total = count($sessionMessages);

    if ($total <= $maxMessages) {
        return array_map(
            fn ($m) => ['role' => $m->get('role'), 'content' => $m->get('content')],
            $sessionMessages,
        );
    }

    $recentCount = min(4, $total);
    $cutoff = $total - $recentCount;
    $olderStart = max(0, $total - $maxMessages);
    $trimmedCount = $olderStart;

    // ... truncate older assistant responses, inject trim notice ...
}

The last four messages (two exchanges) are always kept in full. When messages are dropped, the first kept user message gets a notice prepended: [Earlier conversation trimmed — N messages]. This keeps the model aware that context was cut without burning tokens on the full history.

This alone puts a ceiling on input token growth for long sessions — but it doesn’t address cost within a single agentic turn.

Per-task turn budgets

Agentic tasks vary in how many tool calls they need. A calendar lookup needs two or three turns. A research task may need forty. Treating them the same wastes tokens on simple tasks and starves complex ones.

NativeAgentClient classifies each request by keyword-matching the first user message and looks up a turn limit from a table:

private const DEFAULT_TURN_LIMITS = [
    'quick_lookup'     => 5,
    'email_compose'    => 15,
    'brief_generation' => 10,
    'research'         => 40,
    'general'          => 25,
    'onboarding'       => 30,
];

These are the default limits. Workspaces can override them per session via turnLimitsOverride, and individual calls can pass a hard turnLimitOverride. Session metadata stores the applied limit and turns consumed so that multi-part continuations pick up where they left off.

When the agent approaches its limit — specifically, one turn before it would hit the cap — it fires an onNeedsContinuation callback rather than cutting off silently:

if ($turnLimit > 1 && $turnsWithinCall >= $turnLimit - 1) {
    if ($onNeedsContinuation !== null) {
        $onNeedsContinuation([
            'turns_consumed' => $turnsConsumed,
            'task_type'      => $taskType,
            'message'        => 'I need more turns to complete this task. Continue?',
        ]);
    }

    break;
}

The frontend receives this as a progress event and can prompt the user to continue, rather than getting a half-finished response with no explanation.

Tool result truncation

Each tool result returned to the model is also bounded. NativeAgentClient caps tool results at 2,000 characters by default. Gmail message bodies get a tighter 500-character cap since they tend to be verbose and the agent rarely needs the full body to act.

private const TOOL_RESULT_MAX_CHARS = 2000;
private const GMAIL_BODY_MAX_CHARS  = 500;

The truncation appends [truncated] so the model knows the data was cut rather than incomplete. Tool results bound the payload size per turn; model selection determines how expensive each of those tokens is.

Model selection per workspace

The model isn’t global. ChatStreamController::resolveChatModel() checks the workspace entity for an anthropic_model field first, then falls back to the ANTHROPIC_MODEL environment variable, then to the application default (claude-sonnet-4-6).

private function resolveChatModel(?Workspace $workspace): string
{
    $workspaceModel = $workspace?->get('anthropic_model');
    if (is_string($workspaceModel)) {
        $trimmed = trim($workspaceModel);
        if ($trimmed !== '' && isset(self::ALLOWED_ANTHROPIC_MODELS[$trimmed])) {
            return $trimmed;
        }
    }

    return $this->resolveDefaultModel();
}

Only models in the allowlist are accepted:

private const ALLOWED_ANTHROPIC_MODELS = [
    'claude-opus-4-6'            => true,
    'claude-sonnet-4-6'          => true,
    'claude-haiku-4-5-20251001'  => true,
];

This means a workspace running a high-volume, low-complexity workflow can be pinned to Haiku, while a research-heavy workspace uses Opus — without any code changes. Per-workspace model selection works until the API disagrees.

Automatic model degradation on rate limits

Even with per-workspace model selection, rate limits happen. When the API returns a rate limit error after three retries, NativeAgentClient degrades to the next cheaper model rather than failing the request:

private const MODEL_DEGRADATION = [
    'claude-opus-4-6'           => 'claude-sonnet-4-6',
    'claude-sonnet-4-6'         => 'claude-haiku-4-5-20251001',
    'claude-haiku-4-5-20251001' => null,
];

When the fallback is null (already at the cheapest tier), the request fails and the error is surfaced. Escalation works in reverse — a non-rate-limit API error tries a more capable model before giving up.

Both transitions emit a progress event to the frontend so the user sees something like “Rate limit exhausted on claude-opus-4-6, falling back to claude-sonnet-4-6” rather than a spinner freeze.

Prompt caching to reduce repeated token costs

System prompts and tool definitions are sent on every API call. Anthropic’s prompt caching lets you mark these as cacheable, so the model can reuse previously processed tokens at a fraction of the cost.

NativeAgentClient applies cache_control: ephemeral to two locations before each API call:

$cachedSystem = [[
    'type'          => 'text',
    'text'          => $systemPrompt,
    'cache_control' => ['type' => 'ephemeral'],
]];

// Mark last tool definition for caching
$cachedTools = $toolDefinitions;
if ($cachedTools !== []) {
    $lastIdx = count($cachedTools) - 1;
    $cachedTools[$lastIdx]['cache_control'] = ['type' => 'ephemeral'];
}

The system prompt and the full tool list are the two largest static inputs. Caching them is the single highest-impact cost reduction for multi-turn sessions. Cached tokens still count against rate limits, so they don’t eliminate the need for turn budgets — but they cut the dollar cost of a long agentic session significantly.

The default cache TTL is 5 minutes. For longer-lived sessions, Anthropic now offers a 1-hour TTL at additional cost: "cache_control": {"type": "ephemeral", "ttl": "1h"}. For an assistant that stays active across a workday, that’s worth evaluating. Caching reduces cost — but without telemetry, you won’t know by how much.

Per-turn token telemetry

Cost visibility requires knowing what each turn actually consumed. After each API response, NativeAgentClient fires an onTelemetry callback with per-turn usage data:

if (is_array($usage) && $onTelemetry !== null) {
    $onTelemetry([
        'turn_number' => $turnsConsumed,
        'task_type'   => $taskType,
        'model'       => $currentModel,
        'usage'       => $usage,
        'turn_limit'  => $turnLimit,
    ]);
}

ChatStreamController receives this and writes a chat_token_usage entity per turn, recording input tokens, output tokens, cache read tokens, and cache write tokens separately:

$entry = new ChatTokenUsage([
    'uuid'               => $this->generateUuid(),
    'session_uuid'       => $sessionUuid,
    'turn_number'        => $turnNumber,
    'model'              => $model,
    'input_tokens'       => (int) ($usage['input_tokens'] ?? 0),
    'output_tokens'      => (int) ($usage['output_tokens'] ?? 0),
    'cache_read_tokens'  => (int) ($usage['cache_read_input_tokens'] ?? 0),
    'cache_write_tokens' => (int) ($usage['cache_creation_input_tokens'] ?? 0),
    'tenant_id'          => $tenantId,
    'workspace_id'       => $workspaceUuid,
    'created_at'         => (new DateTimeImmutable)->format('c'),
]);

$storage->save($entry);

With this data you can see which task types consume the most tokens, whether cache hits are materializing, and whether model degradation events correlate with cost spikes — all at the turn level, not just per session.

How the layers interact

These aren’t independent features. Conversation trimming bounds history growth. Turn budgets bound agentic depth. Tool result truncation bounds per-turn payload size. Prompt caching reduces repeated costs. Model degradation handles rate pressure without hard failures. Telemetry makes all of it observable.

Each layer addresses a different part of the same problem: LLM API costs and reliability are non-deterministic unless you actively shape the inputs and handle the edges. Shipping a production AI feature means shipping all of these, not just the happy path.

Baamaapii