I Built a 7-Agent Sales Pipeline and the Hardest Part Was the Topology

The VORTEX pipeline dashboard showing three Kanban columns — <br>
WATCHING, WARM_LEAD, and HOT_LEAD — with lead cards displaying <br>
intent scores, company names, and signal tags. The live activity <br>
feed on the right shows real-time agent events as they fire.

When I started designing VORTEX — a seven-agent pipeline that turns product usage signals into sales outreach — I made the same mistake most people make with multi-agent systems: I let agents talk to each other. Each one passed its output downstream, each one knew about its neighbor, and the routing logic quietly spread itself across the entire graph. Debugging became archaeology. The fix was boring and obvious in retrospect: one agent handles all routing, and nothing else is allowed to call anything else.

The VORTEX pipeline dashboard showing three Kanban columns — <br>
WATCHING, WARM_LEAD, and HOT_LEAD — with lead cards displaying <br>
intent scores, company names, and signal tags. The live activity <br>
feed on the right shows real-time agent events as they fire.

What the System Does

VORTEX is a seven-agent autonomous sales intelligence platform. It consumes raw behavioral events from a B2B SaaS product and produces scored leads, drafted outreach emails, autonomous voice calls via VAPI, and Slack alerts — all without a human in the loop until approval time.

The seven agents and their jobs:

  • Agent 1 — Behavioral Scout: Receives the raw webhook event, normalizes it into a typed activity atom, writes to Firestore.
  • Agent 2 — Intent Architect: Takes an activity atom, calls Groq (llama3-70b-8192), returns a score from 0–100 plus a tier classification (POWER_USER, AT_RISK, PASSIVE).
  • Agent 3 — Persona Scriptwriter: Takes the scored lead, calls Groq again, returns a drafted email with a subject line, body, and a separate sales contact note.
  • Agent 4 — VAPI Voice Caller: Runs on a schedule. Fetches HOT leads with call consent from Firestore, fires VAPI calls, writes outcomes back.
  • Agent 5 — Data Custodian: Runs daily. Fetches leads older than 30 days and deletes them.
  • Agent 6 — Social Intel Scraper: Runs every 6 hours. Hits Reddit and YouTube via Jina AI, runs the results through Groq for sentiment extraction, writes to a product_intelligence Firestore collection.
  • Agent 7 — Executive Router: The only agent that talks to other agents.

Workflow orchestration runs on Cascadeflow. The agent audit trail — what every agent saw, decided, and output for a given lead — is handled by Hindsight. The React dashboard reads from Firestore in real time via onSnapshot listeners. There’s no polling, no message queue, no additional read layer.

The Routing Architecture

Here’s how the main pipeline flows:

Your SaaS  →  POST /webhook/vortex-event
                    ↓
           Agent 1 — Behavioral Scout
           Normalizes payload → activity atom → Firestore
                    ↓
           Agent 7 — Executive Router
           Reads atom, applies thresholds, routes
                ↙         ↘
       Agent 2            Agent 3
  Intent Architect    Persona Scriptwriter
  Groq: score+tier    Groq: email draft
                ↘         ↙
               Firestore leads
                    ↓
            React Dashboard
            onSnapshot listeners

Agents 4, 5, and 6 run on independent schedule triggers and don’t connect to this webhook chain.

Architecture diagram showing the star topology of the VORTEX <br>
agent pipeline. Your SaaS sends a webhook to Agent 1, which <br>
normalizes the payload and passes it to Agent 7. Agent 7 branches <br>
to Agent 2 and Agent 3, both of which write back to Firestore. <br>
The React dashboard reads from Firestore via onSnapshot. Agents <br>
4, 5, and 6 appear separately on the right with clock icons, <br>
each connecting only to Firestore on independent schedules.

The reason Agent 7 is the only router — and this took me longer to land on than it should have — is that conditional logic in pipelines has a way of migrating toward whoever calls next. In my first sketch, I had a linear chain: Agent 1 called Agent 2, Agent 2 called Agent 3, Agent 3 called Agent 7. Clean. But then I needed to handle cases: skip the email draft if the score is below 40; fire the Slack alert immediately if the score is above 90; queue a VAPI call if call consent is set and urgency is HIGH.

In a linear chain, those conditions have to live in the agent that precedes the branch point. Agent 3 would have needed to decide whether to call Agent 7 based on the score — but Agent 3’s job is email generation. It shouldn’t know anything about Slack alerts. You end up with routing logic scattered across multiple agents, each making partial decisions that are hard to trace independently.

The fix is a star topology. Agent 7 is the only node with outbound edges to other agents in the Cascadeflow graph. Every other agent is a leaf: it accepts input, does one thing, and returns output. All conditional logic lives in Agent 7’s Decision Logic node:

// Agent 7 — Executive Router: Decision Logic (n8n Code node)
const { intent_score, urgency, call_consent } = leadData;

const tier = intent_score >= 80 ? 'HOT_LEAD'
           : intent_score >= 50 ? 'WARM_LEAD'
           : 'WATCHING';

if (tier === 'HOT_LEAD') {
  await callAgent(2, leadData);      // Full intent classification
  await callAgent(3, leadData);      // Draft personalized email
  await fireSlackAlert(leadData);    // Immediate #sales notification

  if (call_consent && urgency === 'HIGH') {
    await enqueueVAPICall(leadData); // Agent 4 picks this up on schedule
  }
}

if (tier === 'WARM_LEAD') {
  await callAgent(3, leadData);      // Email draft only — no Slack blast
}

await updateFirestore('leads', leadId, { status: tier, intent_score });

When a lead doesn’t get a Slack alert, I look at one file. When I need to add a new routing condition — pause outreach during a trial’s first 48 hours, for example — I add it to one place. The Cascadeflow graph definition enforces this: leaf agents can’t have outbound edges to sibling agents in the same workflow. It’s a constraint I’d otherwise have to enforce manually and would inevitably forget.

Normalizing Before You Think

Agent 1 does nothing except normalization. This sounds obvious, but it’s easy to skip when you’re prototyping quickly and every event source sends slightly different field names.

// Agent 1 — Normalize Activity Atom (n8n Code node)
const atom = {
  user_id:            payload.user_id,
  event_type:         payload.event || payload.event_type,
  feature:            payload.feature || payload.feature_name,
  session_mins:       payload.session_duration_mins ?? payload.session_mins ?? 0,
  teammates_invited:  payload.teammates_invited ?? 0,
  api_calls_today:    payload.api_calls_today ?? 0,
  call_consent:       payload.call_consent === true,
  email_consent:      payload.email_consent !== false,
  plan:               payload.plan ?? 'unknown',
  timestamp:          new Date().toISOString(),
};

After this runs, every downstream agent operates on an atom with a fixed shape. Agent 2 never needs to defensively null-check session_duration_mins versus session_mins. Agent 7 never gets a surprise undefined in its threshold comparison. The normalization boundary means inconsistent upstream sources are someone else’s problem.

Half the reliability issues I’ve seen in event-driven pipelines come from agents that have accumulated defensive checks because they’ve been burned by inconsistent upstream payloads. The correct fix isn’t more defensive checks — it’s normalizing once, at the entry point, and letting everything else assume clean data.

Making Agent Decisions Observable

The hardest part of running a multi-agent system isn’t getting it to work. It’s figuring out why it didn’t work for a specific lead three hours ago. Firestore shows you the current state. It doesn’t show you the reasoning chain that produced it.

My first attempt at this was a manual agent_logs Firestore collection. Each agent wrote structured entries keyed by lead ID. It worked well enough until the log format started drifting between agents — Agent 2 wrote classification, Agent 7 wrote tier, and the field names for timestamps were inconsistent across all seven. Reconstructing a full decision chain meant querying multiple documents, joining them by timestamp, and hoping nothing was written out of order. When Agent 7 made a routing decision based on what Agent 2 returned, you could only verify that by opening two separate documents and comparing timestamps by hand.

This is where Hindsight replaced the manual approach. Instead of each agent writing to its own log structure, every decision gets written to Hindsight keyed by lead ID — input received, LLM completions for Agents 2 and 3, routing outcome from Agent 7, all of it in one place. Agent memory means those decisions are queryable after the fact as a single ordered chain: pull up any lead ID and get the full reasoning sequence without joining anything manually.

In the dashboard, this surfaces as the Debate Log: a terminal-style view that replays what each agent saw and decided for any selected lead. The React component replays the Hindsight-stored log entries line by line:

// AgentActivity.jsx — replays Hindsight log entries for a given lead
const allLines = DEBATE_LOG.flatMap(block => [
  {
    isHeader: true,
    agent:    block.agentName,
    time:     block.time,
    agentId:  block.agent,
  },
  ...block.lines,  // The actual Hindsight-stored log lines for this agent's run
]);

For a lead like Rahul Sharma at FinTrack Inc. — who hit his API export limit mid-session — the output looks like this:

[14:03:01] > AGT-01 — BEHAVIORAL SCOUT
  Event: api_limit_hit · Session: 47 min
  API calls today: 98 / 100
  Teammates invited: 3 — adoption signal detected
  → Routing to Executive Router

[14:03:02] > AGT-02 — INTENT ARCHITECT
  Invoking Groq llama3-70b-8192...
  Classification: POWER_USER
  Intent score: 91 / 100 · Urgency: HIGH
  Primary pain: USAGE_LIMIT

[14:03:03] > AGT-03 — PERSONA SCRIPTWRITER
  Subject: You hit the Data Export limit — here's how to unblock your team
  Body: 178 words · Personalization tokens: 4

[14:03:05] > AGT-07 — EXECUTIVE ROUTER
  Score 91 ≥ 80 → HOT_LEAD tier
  ✓ Slack fired → #sales
  ✓ Email queued for approval
  Pipeline complete · 4.2s

The Debate Log terminal in the Agent Activity tab, showing the <br>
full reasoning chain for a HOT lead. Four agent blocks are visible: <br>
AGT-01 Behavioral Scout captures the api_limit_hit event, AGT-02 <br>
Intent Architect returns a score of 91 and classifies POWER_USER, <br>
AGT-03 Persona Scriptwriter outputs a 178-word email draft, and <br>
AGT-07 Executive Router fires the Slack alert and queues the email. <br>
Total pipeline time: 4.2 seconds.

That’s not a trace I reconstructed manually. That’s what the Hindsight log gives you for every lead, without any additional instrumentation. The difference between having this and not having it is the difference between debugging a distributed system and guessing at one.

Firestore as the Shared Contract

The seven agents don’t share memory directly. The only state they share is Firestore, and the security rules define the contract for who can write to what:

// firestore.rules
match /leads/{leadId} {
  allow read:  if request.auth != null;
  allow write: if request.auth != null;  // Agents 1, 7 write; 2, 3 read
}

match /product_intelligence/{docId} {
  allow read:   if true;                 // Dashboard reads publicly
  allow write:  if request.auth != null; // Agent 6 writes only
}

match /activity_feed/{eventId} {
  allow read:   if true;
  allow create: if true;   // All agents append; no agent deletes
}

The React dashboard hooks into these collections with onSnapshot listeners. Any Firestore write from any agent propagates to the UI in real time:

// hooks/index.jsx
export function useLeads() {
  const [leads, setLeads] = useState(INITIAL_LEADS);

  useEffect(() => {
    const q = query(
      collection(db, 'leads'),
      orderBy('intent_score', 'desc'),
      limit(50)
    );
    return onSnapshot(q, (snapshot) => {
      const fbLeads = snapshot.docs.map(doc => ({
        id: doc.id,
        ...doc.data(),
      }));
      if (fbLeads.length > 0) setLeads(fbLeads);
    });
  }, []);

  // ...
}

I considered a message queue for agent-to-agent output. The problem is that the dashboard needs a queryable, consistent view of the current state of all leads — not just a stream of events. Firestore gives you both. Agents write to it, the dashboard reads from it in real time, and there’s no additional infrastructure to maintain.

The Agent Activity tab showing a 4x2 grid of agent cards. Agent 7 <br>
Executive Router has a yellow glowing border indicating PROCESSING <br>
state. Its workflow node pipeline is visible inside the card — <br>
Webhook Trigger → Decision Logic → Update Firestore → Slack Alert <br>
— with the active node highlighted in yellow as the pipeline walks <br>
through each step in real time.

What I’d Do Differently

Strict input validation at Agent 1. The current normalization is lenient — missing fields get defaults. In production, a malformed payload that makes it past Agent 1 is significantly harder to debug than one that fails loudly at the boundary with a clear error message. Leniency at the entry point is a false kindness.

Per-agent Hindsight namespaces. All agent log entries currently go into the same Hindsight workspace, queryable by lead ID. As the system scales, I’d want per-agent namespaces to run fleet-level analytics: how often does Agent 2 return POWER_USER vs AT_RISK classifications? What’s the distribution of urgency scores over the last 30 days? That’s useful signal for tuning the Groq prompts and isn’t something you can reconstruct efficiently from per-lead traces.

Explicit routing conditions in the Cascadeflow graph. The routing thresholds — score ≥ 80 for HOT_LEAD, score ≥ 50 for WARM_LEAD — currently live in Agent 7’s Code node. I’d rather declare them in the Cascadeflow graph definition itself, so someone reading the topology can see “Agent 7 routes to Agent 3 when score ≥ 50” without needing to read the implementation. Separating the routing declaration from the routing implementation makes it much easier to reason about at a system level.

Takeaways

Things that applied here and will generalize to any multi-agent pipeline:

One router, not a mesh. Routing logic grows over time — new conditions, new exceptions, new downstream integrations. You want all of that growth concentrated in one place, with one set of tests, and one place to look when something doesn’t route correctly.

Normalize at the entry point. Agents that are defensive about their input shape are agents that are compensating for a problem upstream. Fix the problem upstream. One normalization pass at the boundary is worth ten defensive null-checks distributed across six agents.

Build the audit trail first. In any system where LLMs are making decisions, you need to be able to reconstruct what each model saw and returned for any given input. Hindsight-style observability isn’t something you bolt on after debugging gets painful — it’s something you wire in from day one, because the debugging is going to happen and you’ll want it to take minutes, not hours.

Shared state beats direct messaging for observable systems. Firestore as the shared layer means the dashboard and the agents are reading and writing the same data, with the same real-time guarantees. A separate message queue would have required building a distinct read model for the UI and introduced another system to operate.

Let leaf agents be dumb. Agents 2, 3, 4, 5, and 6 each do one thing. They’re easy to test in isolation, easy to swap out, and easy to reason about. Complexity lives in Agent 7, where it’s explicit and intentional. The moment a leaf agent starts making decisions about what should happen next, you’ve got routing logic in the wrong place.

Closing

This started as a lead scoring webhook. It became something more useful once I stopped thinking about agents as a chain and started treating routing as a first-class design decision — not an afterthought wired into whichever agent happened to sit upstream.

The topology is boring to look at. One router, six leaves, shared Firestore, no direct agent-to-agent memory. But boring topologies are the ones that are still working six months later, that you can debug at 2am without reconstructing context, and that you can hand to another engineer without a 45-minute walkthrough.

The complexity lives in Agent 7. That’s where it belongs.

Frontend HLD: Understanding Different Types of Applications

Modern frontend applications are no longer limited to simple webpages and forms. Nowadays, they handle large datasets, complex user interactions, and intensive computations, all inside a single browser.

And I believe it is a good idea to understand the basic types of applications and their nature, so that whenever you start working on an application, a problem, or even a feature, you already have some foundational understanding in place. This helps guide your intuition and enables you to architect better, more scalable applications.

So, Let’s learn these types:

Data Heavy Applications

These are the applications that deals with massive datasets and presentation of the data in different kinds of visualizations such as charts and tables.

Example:

  • Finance trading and banking dashboards
  • Social media feeds such as facebook, instagram, reddit and even dev.to
  • Analytics dashboards and Log processing systems
  • Ecommerce platforms such as Amazon, Flipkart, EBay

Common problems:

  • performance issue due to large lists of data
  • rendering a large number of DOM elements can be time-consuming and resource-intensive for browser
  • UI freezing issues
  • Complexity of data structure and data management and state

Common solutions: Our goal is to reduce the memory usage and page load times while keeping the UI responsive

  • pagination
  • infinite scroll or lazy loading for on demand data.
  • windowing: Windowing is rendering only the visible portion of a list to the screen. react window or react-virtualized are two libraries used in react to achieve this.
  • web workers: Offloading heavy data processing or sorting tasks to a background thread to prevent the UI from freezing

Compute heavy applications

Applications that require intensive data processing, calculations and heavy rendering, comes under compute heavy applications category. These are often CPU and GPU heavy.

Example:

  • video rendering: Youtube, photo editors, video editors
  • Google Maps
  • 3D Modeling tools such as AutoCAD web
  • Scientific simulations apps
  • AI/ML apps running inference with TensorFlow.js
  • Browser games

Common Problems: The primary bottleneck is often the single-threaded nature of JavaScript as it is handling both rendering and business logic

  • Main thread blocking (intensive computations run on same thread as UI rendering)
  • Frame drops and slow rendering
  • DOM overload, Memory leaks due to unreleased listeners
  • Browser and device variabilities

Common Solutions:

  • Using web workers to offload heavy computations to background thread to keep UI free
  • Windowing
  • Throttle and debounce
  • Memoization of expensive functions and calculations

Realtime Application

Applications that process inputs and outputs instantaneously within a strict time frame and with minimum latency.

Example:

  • Chat apps like whatsapp, telegram
  • Video conferencing apps like zoom, google meet
  • Online games and IoT apps
  • Transportation and logistics like Uber, Swiggy etc.

Common problems:

  • Latency issues: delay between an action and its result
  • High CPU and memory usage due to always-on nature of realtime apps
  • Ordering of events and state synchronization
  • Security risks

Common solutions:

  • Optimistic updates: updating UI immediately assuming server request will succeed
  • Using centralized state to maintain a single source of truth
  • Throttle and debounce
  • Web workers to offload data to background thread

Content Driven Applications

These are not typical applications that focus on high interactivity. Instead, these apps focus on fast delivery of content and readability of information. These employ strategies such as Static Site Generation (SSG) or Server-Side Rendering (SSR) for fast loading and SEO.

Examples:

  • Blogs and new websites and web apps
  • E-Commerce and Product catalog apps
  • Documentations
  • Marketing apps, Video streaming apps (youtube), etc.

Common Problems:

  • SEO + Web Vitals for increased visibility on search engines
  • Asset heavy nature forces you to deal with large images, videos and unoptimized media.
  • Managing third party scripts and large frameworks, making sure you don’t choke the browser and cause slow interactivity
  • Rendering optimizations to support display of thousands of items
  • Unpredictable traffic, Responsive design, Accessibility, and data managements are crucial things that you need to keep in check.

Common Solutions:

  • Performance: Lazy loading for images, code splitting for reduced bundle size and use of modern formats for both images and videos such as Webp and avif.
  • Utilizing rendering strategies such as SSG and SSR
  • Virtualization and caching for large data sets rendering
  • Using headless CMS and frontend application to decouple data management layer and presentation layer.

Transactional applications

Applications such as payment gateways, booking and banking systems are covered in this category. These require high security, robust state management and realtime updates.

Example:

  • Payment gateways like stripe, paypal, razorpay
  • Booking systems
  • Online banking systems

Common Problems:

  • High security and dealing with sensitive data + compliance requirements (PCI Compliance)
  • Robust state management to maintain consistency
  • Error handling and safe retry logics
  • Race conditions and duplicate submissions

Common Solutions:

  • Tokenization: Instead of sending raw credit card numbers, frontends use tokens provided by the gateway. This secures the data and limits compliance scope.
  • use of SSL and HTTPS
  • Introducing strict validations on both Frontend and Backend
  • Implement Idempotency Keys: Ensure the frontend sends a unique key for each transaction to prevent double-charging in case of retries.

CRUD heavy applications

CRUD heavy applications focus on data management through Create, Read, Update and Delete operations. They require robust form handling, data tables, validations and filtering options.

Example:

  • Admin panels and dashboards
  • Inventory management systems, and CMS (e.g. Wordpress)
  • Helpdesk portals,
  • CRMs
  • Task management and project management tools like Jira, Trello

Common Problems:

  • Rendering massive datasets in the form of grids and tables
  • Overfetching and underfetching of data
  • Complex form states and overall state management
  • Validations and sanitization of form data
  • Filtering, sorting and search requirements across app

Common Solutions:

  • Virtualization and pagination of data
  • Debounce and throttle for search bars and inputs
  • Using optimistic UI updates
  • Decoupling UI and Data structures
  • Consistency in both form validation and feedback when handling data

Event Driven Applications

Applications that rely on event based models such as notification systems, IoT modules, Microservices, comes under this category. Events are constantly produces and consumed in these kinds of applications and they use Pub/Sub Pattern to communicate

Example:

  • Realtime order tracking
  • Collaborative editors like Google Docs, Figma
  • IoT control panels
  • Stocks and trading platforms
  • Notification systems in social media platforms

Common Problems:

  • Order of events, race conditions, state sync
  • High volume of events can flood the UI thread
  • Debugging and state management
  • Handling network failures

Common Solutions:

  • Strict state management to keep UI predictable
  • Implement idempotent consumers to allow safe processing of the same event multiple times
  • Using timestamps and versions for maintaining event orders
  • Using Optimistic UI updates
  • Avoid blocking UI thread by using Server Sent Events, Websockets, and data filtering.

Hybrid applications

Applications that are a combination of above mentioned apps, example, E-Commerce Applications such as Amazon, Flipkart, eBay etc. They have all sorts of features ranging from chat solutions, transactional layers, data heavy UIs and have tons of features running parallelly in foreground and background.

Every frontend application has different priorities, constraints, and engineering trade-offs. A realtime chat system cannot be optimized the same way as a content-driven website. Knowing the nature of your application or even the feature, helps you take the right approach from the beginning.

Ultimately, great frontend architecture is not about using every advanced technique, it’s more about brainstorming and apply the right solutions to the right kind of problem.

I Built and Deployed a Production Web Backend in Raw C++20

I built something most developers would probably tell me not to build:

A production web backend in raw C++20.

Building a production web server in raw C++20.
No frameworks. No external libraries.
Zero dependencies beyond the OS itself.

Not because I had to. Because I wanted to understand
infrastructure at the lowest level possible.

This is the full story — every obstacle, every fix,
every command — from a blank CLion project to a live
HTTPS secured domain at aureonops.dev.

Why C++?

Most backend tutorials point you toward Node.js,
Python, or Go. Fast to set up, large ecosystems,
plenty of tutorials.

But I kept asking the same question:

What’s actually happening underneath?

C++ forced me to find out. Every connection, every
socket, every HTTP response — I had to build it
myself or it didn’t exist.

Initial local observations:

  • Small native binary
  • Very low memory usage
  • Fast startup
  • No framework/runtime dependency layer

Formal benchmarks are planned for a future release.

The Stack

Before we get into the journey, here’s what we
ended up with:

Engine → C++20 (raw WinSock2 / POSIX sockets)
OS → Ubuntu 24.04 LTS
Proxy → Nginx
DNS / CDN → Cloudflare
Security → SSL/TLS + UFW firewall
Persistence → systemd service
Host → DigitalOcean $6 VPS
Domain → aureonops.dev

Building the Backend

The core of AUREON is a single main.cpp file.
No router library. No HTTP parsing library.
Raw socket programming.

The server works like this:

// Create socket
SocketType serverSocket = socket(AF_INET, SOCK_STREAM, 0);

// Bind to port 8080
sockaddr_in serverAddr;
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(8080);
serverAddr.sin_addr.s_addr = INADDR_ANY;

bind(serverSocket, (sockaddr*)&serverAddr, sizeof(serverAddr));
listen(serverSocket, 5);

// Accept connections
while (true) {
    SocketType clientSocket = accept(serverSocket, nullptr, nullptr);
    char buffer[30000] = {0};
    recv(clientSocket, buffer, sizeof(buffer), 0);
    // Parse request, build response, send it back
}

That’s the entire connection model. Accept a request,
handle it, close the connection, repeat.

Cross Platform from Day One

One non-negotiable requirement — it had to compile
on both Windows and Linux.

The solution was clean #ifdef guards:

#ifdef _WIN32
#include <winsock2.h>
#pragma comment(lib, "ws2_32.lib")
#else
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#endif

#ifdef _WIN32
using SocketType = SOCKET;
#else
using SocketType = int;
#endif

Developed on Windows in CLion. Deployed on Ubuntu.
Same codebase. Zero changes.

API Routes

Request routing is done via simple string matching
on the raw HTTP buffer:

if (request.find("GET /api/status") != std::string::npos) {
    response =
        "HTTP/1.1 200 OKrn"
        "Content-Type: application/jsonrn"
        "Access-Control-Allow-Origin: *rnrn"
        "{"status": "online", "engine": "C++"}";
}

Not elegant by framework standards. But completely
transparent. You know exactly what every byte of
that response contains.

File Serving

Static files are served directly from disk:

std::string serveFile(const std::string& path, 
                      const std::string& contentType) {
    std::ifstream file(path);
    std::stringstream buffer;
    buffer << file.rdbuf();
    return "HTTP/1.1 200 OKrn"
           "Content-Type: " + contentType + "rnrn" +
           buffer.str();
}

The frontend lives in a /frontend folder —
index.html, style.css, script.js.
The C++ server reads and serves them on every request.

The First Deployment Disaster 💀

Everything worked on localhost. Time to go live.

I spun up a DigitalOcean Ubuntu droplet, cloned
the repo, built the binary, and ran:

ufw enable

And immediately locked myself out of SSH.

The mistake? I enabled UFW before adding port 22.
UFW’s default is deny all incoming — including SSH.
The server was unreachable. Web console timed out.
Complete lockout.

The fix: Destroy the droplet. Start over.
This time with the correct order:

# Allow SSH FIRST — before anything else
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 8080/tcp

# THEN enable
ufw enable

Lesson learned the hard way. UFW order of operations
matters. Always allow SSH before enabling the firewall.

Cross Platform Build Issue

The Linux build hit another wall immediately:

fatal error: winsock2.h: No such file or directory

The CMakeLists.txt was linking ws2_32 unconditionally
— a Windows only library. Linux doesn’t have it.

Fix:

if(WIN32)
    target_link_libraries(cpp_backend_test ws2_32)
endif()

Three lines. Problem gone. Build succeeded.

systemd — Making It Persistent

A server that dies when you close the terminal
isn’t a server. It’s a demo.

The fix is a systemd service:

[Unit]
Description=AUREON C++ Backend
After=network.target

[Service]
WorkingDirectory=/root/aureon-cpp-backend
ExecStart=/root/aureon-cpp-backend/server
Restart=always
RestartSec=3
User=root

[Install]
WantedBy=multi-user.target
systemctl enable aureon
systemctl start aureon

The systemd bug that looked like a backend failure

At one point the service was active, but the site returned:

404 – File not found

The binary was running, but it could not find frontend/index.html.

The fix was not in C++ at all. It was systemd:

WorkingDirectory=/root/aureon-cpp-backend


That is a real engineering lesson.

---

### 6. Mention the Cloudflare 521 issue

This is another great struggle section.

Add:

md

The Cloudflare 521 problem

After the domain was connected, Cloudflare returned:

521 Web Server Is Down

The backend was alive. curl http://127.0.0.1:8080 returned the correct HTML.

That narrowed the issue down to the path between Cloudflare, Nginx, and the origin SSL configuration.

The lesson: when debugging production, test each layer separately.

  • Is the backend alive locally?
  • Is Nginx proxying correctly?
  • Is DNS pointing correctly?
  • Is SSL mode compatible with the origin?

Debugging checklist I wish I had earlier

# Is the backend alive?
curl http://127.0.0.1:8080

# Is the backend listening?
ss -tulnp | grep 8080

# Is the service running?
systemctl status aureon

# What did the service log?
journalctl -u aureon -n 50 --no-pager

# Is Nginx config valid?
nginx -t

# Is the firewall correct?
ufw status

Now AUREON:
- Starts automatically on boot
- Restarts automatically on crash
- Runs 24/7 without a terminal session

---

## Nginx — The Right Way to Expose a Backend

Running a C++ binary directly on port 80 is 
not how production works.

Nginx sits in front as a reverse proxy:

nginx
server {
listen 80;
server_name aureonops.dev;

location / {
    proxy_pass http://localhost:8080;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}

}




Benefits:
- Clean port 80/443 — no `:8080` in the URL
- SSL termination handled by Nginx + Cloudflare
- Easy to add rate limiting, caching later
- Industry standard architecture

---

## Cloudflare — Free Enterprise Grade Protection

Pointing the domain through Cloudflare's orange cloud 
gave AUREON:

- **Free SSL/TLS** — HTTPS on aureonops.dev
- **Cloudflare edge layer — DNS, proxying, TLS, and CDN capability
- **DDoS protection** — built in, automatic
- **DNS management** — fast, reliable

HTTPS is enabled through Let’s Encrypt on the origin server, with Cloudflare handling the edge layer. — Cloudflare encrypts 
all the way to the origin server.

---

## The Final Architecture

User Browser
│
⌄
┌─────────────┐
│  Cloudflare │  < DNS + CDN + DDoS + SSL
└──────┬──────┘
│ HTTPS
┌──────⌄──────┐
│    Nginx    │  < Reverse proxy, port 80/443
└──────┬──────┘
│ HTTP
┌──────⌄──────┐
│   AUREON    │  < C++20 binary, port 8080
│  C++ Engine │
└─────────────┘
│
DigitalOcean
Ubuntu 24.04
$6/month VPS

---

## The Result

[aureonops.dev](https://aureonops.dev) is live.

A raw C++ binary handling real HTTP requests, 
serving a production frontend, secured by Cloudflare, 
proxied by Nginx, running 24/7 as a systemd service 
on a $6 VPS.

Performance characteristics:
- Binary size: < 500KB
- Memory usage: < 10MB
- Cold start: < 10ms
- Zero runtime dependencies

---

## What I Learned

**1. Low level is not harder — it's more transparent**
Frameworks hide complexity. Raw sockets reveal it.
Both have their place but understanding the low level
makes you a better developer at any level.

**2. Production is a different beast than localhost**
UFW, systemd, Nginx, SSL — none of this exists 
on your laptop. The gap between "it works locally" 
and "it works in production" is where real 
engineering happens.

**3. C++ for web backends is underrated**
Not for every use case. But for performance 
critical, low latency, resource constrained 
systems — nothing comes close.

**4. The stack matters less than the fundamentals**
Understanding sockets, HTTP, file serving, process 
management — these skills transfer to every stack.

---

## What's Next for AUREON

AUREON is evolving into a high performance backend 
infrastructure platform:

- Multi-threaded connection handling
- Built in metrics and telemetry
- One command deployment scripts
- Docker container support
- Developer template packages

The goal: make C++ backend deployment accessible 
to engineers who care about performance without 
the painful setup.

---

## Try It Yourself

The full source code is open source:

**GitHub:** [APO72A/aureon-cpp-backend](https://github.com/APO72A/aureon-cpp-backend)

**Live:** [aureonops.dev](https://aureonops.dev)

If you have questions, drop them in the comments.
Happy to go deep on any part of this. ⚙️

---

*Built with C++ · Deployed on DigitalOcean · 
Secured by Cloudflare*

Token Deep Dive #2: The /50 That Did Nothing

The other day, I noticed something funky with my accordion component’s hover state.

It wasn’t broken in an obvious, “error-thrown-in-console” kind of way. The background color changed when I hovered over it, sure, but the opacity was completely gone. I had explicitly coded hover:bg-background-muted/50, but instead of a nice, subtle 50% tint, I was getting a solid, opaque slap of color. Same went for my glassmorphism effects (bg-white/10) — just pure, blinding white.

At first, I went down the usual rabbit holes: Is it a specificity issue? Did I miss an import somewhere?

It took me longer than I’d like to admit to connect the dots to a change I’d pushed a few days prior: converting every single color in our 7onic Tailwind v3 preset from hardcoded HEX values to CSS var() references.

At the time, I thought I was a genius. I thought I was building the ultimate clean architecture.

Spoiler alert: I wasn’t.

The “Single Source of Truth” That Betrayed Me

The dream was beautiful. I was setting up a token pipeline where figma-tokens.json was the Single Source of Truth. Run one build script, and it spits out eleven different distribution formats — CSS variables, Tailwind presets, TypeScript types, you name it.

The goal was simple: keep the Tailwind v3 preset perfectly synchronized with those Figma tokens without the nightmare of maintaining separate, duplicated HEX values in a JS file.

So I mapped every token reference to a CSS variable:

colors: {
  primary: {
    DEFAULT: 'var(--color-primary)',
  },
  background: {
    muted: 'var(--color-background-muted)',
  },
}

Look at that. Whenever a designer shifted a hex code in Figma, npm run sync-tokens would handle the rest downstream.

Except the moment this shipped, bg-primary/50 died. So did border-border/60, text-foreground/80, bg-gray-500/30. Every single opacity modifier across the entire component library silently stopped working.

The truly embarrassing part? I had literally written the documentation for this pipeline a month earlier, explicitly stating that Tailwind v3 opacity modifiers require decomposable color values. I read it. I wrote it. And then I completely ignored it because I was blinded by how clean the var() refactoring looked. Classic.

Why Tailwind v3 Chokes on CSS Variables

Here’s the technical reality: Tailwind v3 handles opacity modifiers at build time.

When the JIT compiler scans your code and sees bg-primary/50, it looks up primary in your Tailwind config. It expects a raw value — like a HEX or RGB string — that it can physically decompose into individual R, G, B channels to output rgba(R, G, B, 0.5). This is all build-time string manipulation.

When you pass it var(--color-primary), the compiler hits a wall. It has no idea what RGB values that variable will resolve to, because that resolution happens at runtime in the browser. So instead of throwing an error, it just discards the /50 alpha channel entirely and outputs the base color as a solid block.

Tailwind v4 doesn’t suffer from this. It delegates the heavy lifting to the browser using the native color-mix() function:

/* What bg-primary/50 compiles to in Tailwind v4 */
background-color: color-mix(in srgb, var(--color-primary) 50%, transparent);

Since the browser handles the blending at runtime, v4 doesn’t need to know the raw RGB channels at build time. But when I was building this, forcing everyone onto v4 wasn’t an option — and the 7onic token package had to support both v3 and v4.

The Three-Tier Compromise

Fixing the static, primitive colors like gray-500 was easy. I swallowed my pride and reverted them back to raw HEX strings: gray-500: '#78787C'. These core palette shades never change based on the active theme anyway, so hardcoding them in the JS preset was fine, and it instantly brought opacity modifiers back to life.

Semantic colors were a different problem. background-muted needs to resolve to a light gray in light mode and a dark charcoal in dark mode. It has to be a CSS variable at runtime to handle theme toggling — but it also needs to be decomposable at build time for Tailwind v3.

Tailwind v3 has a built-in escape hatch for exactly this dilemma. Instead of 'var(--color-background-muted)', you write 'rgb(var(--color-background-muted-rgb) / <alpha-value>)'. The <alpha-value> is a special placeholder Tailwind swaps out with the actual opacity percentage during compilation. The catch: you need a companion CSS variable that holds only the space-separated R G B numbers, not a full HEX code.

The three-tier breakdown:

Type Preset format Reason
Primitive colors (gray-500, primary-400…) HEX directly Static values, /50 works out of the box
Semantic colors (primary, background…) rgb(var(--*-rgb) / <alpha-value>) Theme-aware at runtime + alpha at build time
Non-color tokens (spacing, radius…) var() Opacity doesn’t apply here

The preset ends up looking like this:

colors: {
  primary: {
    DEFAULT: 'rgb(var(--color-primary-rgb) / <alpha-value>)',
  },
  background: {
    muted: 'rgb(var(--color-background-muted-rgb) / <alpha-value>)',
  },
}

And the theme CSS files expose those raw companion variables:

--color-background-muted: var(--color-gray-100);
--color-background-muted-rgb: 244 244 246;
--color-background-muted: var(--color-gray-700);
--color-background-muted-rgb: 60 60 60;

When the theme changes, the browser swaps the underlying RGB numbers. Tailwind’s pre-baked alpha placeholder handles the transparency. hover:bg-background-muted/50 works correctly in both modes.

Automating 135 Companion Variables Because I Value My Sanity

I had 82 primitive color tokens and 53 semantic colors per theme. Calculating and writing those -rgb channel variables by hand was not happening — it’s tedious and drifts the moment a token value changes.

So I hooked it into sync-tokens.ts. A hexToRgb helper converts #F4F4F6 → "244 244 246" (space-separated, which is what modern CSS color syntax expects):

function hexToRgb(hex: string): string | null {
  const match = hex.replace('#', '').match(/^([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i)
  if (!match) return null
  return `${parseInt(match[1], 16)} ${parseInt(match[2], 16)} ${parseInt(match[3], 16)}`
}

Now every time the build pipeline runs, the script reads the original HEX values from figma-tokens.json, extracts the raw channels, and outputs both variables side by side. The source file stays clean. The -rgb values are derived, not stored — generated automatically, never manually maintained.

For semantic tokens, the script resolves the full reference chain (semantic → primitive → final HEX) for both light and dark modes, emitting the correct channel values per theme.

The One Gotcha That Still Trips People Up

If you’re using @​7onic-ui/tokens in a Tailwind v3 project and opacity modifiers suddenly vanish, 9 times out of 10 it’s a missing theme import.

Importing just variables.css isn’t enough. Because semantic -rgb variables have different values per theme, they live inside light.css and dark.css. If those theme files aren’t imported, the -rgb variables are undefined — and bg-background-muted/50 either renders transparent or drops the background entirely, depending on the browser.

It’s documented right there in the getting started guide. Some lessons are best learned by breaking it first.

Next: v3 and v4 dual support from one token source — how the two presets stay in sync and why the v3 format necessarily does more work.

About 7onic — An open-source React design system where design and code never drift. Free, MIT licensed. Docs and interactive playground at 7onic.design. Source code on GitHub — stars appreciated. More posts in this series at blog.7onic.design. Follow updates on X at @​7onicHQ.

How to Extract Buying Signals from Any User Interview Transcript (Free Method)

Most founders do user interviews wrong.

Not the interview itself — the analysis after. They finish a 45-minute call, feel great about it, write some notes, and move on. Two weeks later when it’s time to make a product decision, they can’t remember what the user actually said.

The worst part? The most valuable insight — buying signals — almost always gets lost completely.

What is a buying signal in a user interview?
A buying signal is any moment where the user expressed willingness to pay, switch tools, or commit to a solution. It doesn’t have to include a price mention. These all count:

  • “I’d switch from “Z” immediately if this had X”
  • “We have budget approved for something like this”
  • “How much does it cost? Because this would save me hours every week”
  • “I’ve been looking for something exactly like this for months”
  • “I’d pay $50 a month for this, no question”

Most researchers focus on objections and feature requests — which are important — but completely miss these moments of purchase intent.

Why buying signals get lost
When you analyze a transcript without a structured framework, buying signals disappear. They appear briefly in the middle of a longer conversation, often sandwiched between complaints or feature requests. Without actively searching for them, they look like any other sentence.

I once found a buying signal buried on page 12 of a transcript — “honestly if the pricing was clearer I’d sign up today” — that completely changed our pricing page strategy. We nearly missed it.

The free method — 5 dedicated extraction passes
Instead of reading the transcript once and trying to capture everything, read it five times — once for each insight category:

Pass 1 — Objections only
Read looking only for concerns, hesitations and blockers. Ignore everything else.
Pass 2 — Feature requests only
Look for explicit asks (“I wish it had X”) and implied needs from workarounds (“I currently do this manually every week”).
Pass 3 — Emotional signals only
Note every moment of frustration, excitement, confusion or anxiety. Look for words like “honestly,” “frustrated,” “finally,” “tired of.”
Pass 4 — Buying signals only
This is the critical pass most people skip. Read only for purchase intent — price mentions, willingness to switch, urgency signals, budget mentions.
Pass 5 — Patterns
Look at everything you extracted. What appears more than once? Those recurring items are your strongest signals.

Minimum thresholds for confidence:

  • 3+ interviews — worth investigating
  • 5+ interviews — high confidence, prioritise this
  • 7+ interviews — near certain, build around this

The result:
After five passes you have five structured lists instead of messy notes. You can now answer: what are the top objections, what features are most requested, who showed buying intent, and what patterns are strong enough to build product decisions around.

If you want to skip the manual process –
I built Genvoxa to automate all five passes simultaneously. Paste any transcript — user interview, sales call, HR session, customer success call — and it extracts all five categories in under 30 seconds. Free forever plan, no credit card needed.

But the manual process works perfectly well if you only have a few interviews. The key insight is the same either way — buying signals need a dedicated extraction pass or they disappear.

Want the full step-by-step guide including how to find patterns across multiple interviews? Read the complete article at genvoxa.com/blog/how-to-analyze-user-interview-transcripts.html

I built ZKP auth so passwords never touch your server

Every time you hear about a major breach, the headline is the same: “Millions of passwords exposed.” Attackers get in, dump the database, and walk away with your users’ bcrypt hashes. Given enough time and a GPU farm, weak passwords crack. Even strong ones end up in breach databases. The root cause is always the same: the password reached your server. It lived in a request body, got hashed, got stored, and now it’s someone else’s problem. What if the password never traveled at all?

What is Zero-Knowledge Proof auth?

Zero-Knowledge Proofs sound academic, but the core idea is surprisingly simple: you can prove you know a secret without revealing what the secret is.

Here’s a real-world analogy. Imagine you want to prove to a bouncer that you know the secret password to a club, but you don’t want to say it out loud where others can hear. Instead, the bouncer gives you a random challenge token and asks you to sign it in a way that only someone who knows the password could. You hand back the signed token. The bouncer checks the signature. You’re in — and you never said the password.

That’s exactly what ZKP auth does at a cryptographic level. Your browser derives a keypair from your password (the password stays in memory, never leaves), uses the private key to sign a server-issued challenge, and the server checks the signature against a stored public key. The server never sees the password. It doesn’t even see a hash of the password. It sees a 32-byte public key and a one-time proof.

No password database means no password database to breach.

How the library works

ZKP Auth is a TypeScript monorepo with four focused packages:

Package What it does
@zkp-auth/core Schnorr proof primitives (Ed25519)
@zkp-auth/server Express middleware — register, challenge, verify
@zkp-auth/client Browser client — key derivation + proof generation
@zkp-auth/react useZKPAuth() hook for React apps

Install

# Server
npm install @zkp-auth/server

# Browser (vanilla)
npm install @zkp-auth/client

# Browser (React)
npm install @zkp-auth/client @zkp-auth/react

Server — three middleware functions, that’s it

// server.ts
import express from 'express';
import cookieParser from 'cookie-parser';
import {
  zkpRegister,
  zkpChallenge,
  zkpVerify,
  InMemoryChallengeStore,
} from '@zkp-auth/server';

const app = express();
app.use(express.json());
app.use(cookieParser());

// Swap InMemoryChallengeStore for Redis/Postgres in production
const store = new InMemoryChallengeStore();

// Your user store — replace with real DB calls
const users = new Map<string, Uint8Array>();

// Route 1 — register: receives and stores the user's public key
app.post(
  '/auth/register',
  zkpRegister({
    savePublicKey: async (userId, publicKey) => {
      users.set(userId, publicKey);
    },
  }),
  (_req, res) => res.json({ ok: true }),
);

// Route 2 — challenge: issues a fresh one-time nonce
app.post(
  '/auth/challenge',
  zkpChallenge({ store }),
  (_req, res) => res.json({ challengeHex: res.locals.zkpChallengeHex }),
);

// Route 3 — verify: checks the proof and issues a JWT cookie
app.post(
  '/auth/verify',
  zkpVerify({
    getPublicKey: async (userId) => users.get(userId) ?? null,
    store,
    jwtSecret: process.env.JWT_SECRET!,
  }),
  (_req, res) => {
    res.cookie('auth', res.locals.zkpToken, {
      httpOnly: true,
      sameSite: 'strict',
      secure: process.env.NODE_ENV === 'production',
    });
    res.json({ ok: true });
  },
);

Client — React hook

// LoginForm.tsx
import { useZKPAuth } from '@zkp-auth/react';

export function LoginForm() {
  const { register, login, logout, isAuthenticated, loading, error, user } =
    useZKPAuth();

  if (isAuthenticated) {
    return (
      <div>
        <p>Welcome, {user?.userId}!</p>
        <button onClick={logout}>Log out</button>
      </div>
    );
  }

  return (
    <>
      {error && <p style={{ color: 'red' }}>{error.message}</p>}

      <form onSubmit={async (e) => {
        e.preventDefault();
        const f = new FormData(e.currentTarget);
        await register(f.get('username') as string, f.get('password') as string);
      }}>
        <h2>Register</h2>
        <input name="username" placeholder="Username" required />
        <input name="password" type="password" placeholder="Password" />
        <button type="submit" disabled={loading}>Register</button>
      </form>

      <form onSubmit={async (e) => {
        e.preventDefault();
        const f = new FormData(e.currentTarget);
        await login(f.get('username') as string, f.get('password') as string);
      }}>
        <h2>Log in</h2>
        <input name="username" placeholder="Username" required />
        <input name="password" type="password" placeholder="Password" />
        <button type="submit" disabled={loading}>
          {loading ? 'Authenticating…' : 'Log in'}
        </button>
      </form>
    </>
  );
}

That’s the entire auth surface. No password ever leaves the browser.

How it works under the hood

The protocol is a Schnorr Proof of Knowledge over Ed25519 with the Fiat–Shamir transform. Here’s the flow in plain English:

BROWSER                              SERVER
──────────────────────────────────────────────────────
1. Derive keypair from password
   (PBKDF2 + SHA-512 — stays in memory)

2. POST /auth/challenge  ──────────►  Issue challenge c (random nonce)
                         ◄──────────  Return c

3. Generate commitment R = r·G
   Compute s = r + c·privKey        (private scalar stays local)
   POST /auth/verify (R, s, userId)

                         ──────────►  Fetch stored pubKey for userId
                                      Verify: s·G == R + c·pubKey
                                      ✓  Issue JWT cookie

The server only ever stores the 32-byte public key — mathematically useless without the private scalar, which only the browser can derive (from the password, which never travels).

Replay attacks are impossible: every challenge c is a fresh one-time nonce that expires server-side after a single use.

The crypto is built on @noble/curves — an audited, zero-dependency library. No custom crypto primitives.

For the full mathematical derivation and threat model, see the Security Model docs.

Get started

npm install @zkp-auth/server        # server
npm install @zkp-auth/client        # browser / vanilla JS
npm install @zkp-auth/client @zkp-auth/react  # React

Pre-1.0 notice: This library has not yet undergone a formal third-party audit. Review the security model before deploying to production.

Stars, issues, and PRs are all welcome. If you’re building something with it, I’d love to hear about it in the comments.

Stop chatting with Claude Code: 3 rules for cleaner context and lower bills

I corrected Claude in a debugging session. It said “you’re right.”

Then it spent the next 20 messages quietly building on the bug I had just disproved.

The bug wasn’t in the model. It was in how I was using context.

Most developers I know use Claude Code like a chat window. One terminal, one session, all day long. Backend question in the morning, frontend bug after lunch, a quick “what should I order for lunch” mixed in somewhere, and an architecture decision around 4pm.

By the end of the day, Claude is unhelpful in subtle ways. It mixes the codebases up. It forgets corrections you made an hour ago. The answers get longer, the relevant parts shorter. And you’re closer to your weekly rate limit than you should be — or, if you’re on API access, your bill is bigger than yesterday — for the same amount of actual work.

None of that is a model problem. It’s a context problem. The window is not memory — it’s a working surface, and you have to manage it like one.

The three symptoms

1. Cross-topic confusion

A long session has no concept of “topic.” Every new message ships the entire prior conversation to the model. If auth.users appeared in your prompt at 10am, it’s still in the prompt at 4pm — even if you’ve been working in a completely unrelated repo for the last three hours.

So when you ask “should we cache this with Redis?” at 4pm, you’re not asking a clean question. You’re asking a question against a 4-hour-old soup of:

  • The 10am backend table schema
  • A 12pm offhand mention of a deadline
  • The 2pm frontend stack discussion
  • That weird tangent about your dev container at 3pm

The model answers based on the whole soup. It pulls patterns from one task into another. It references functions that exist in a different repo. It gets confidently wrong because it has too much context, not too little.

You can write better prompts forever — it won’t fix this. The fix is realizing the surface itself is dirty.

2. Post-correction persistence — the “you’re right” trap

This is the one that costs me the most time.

I was debugging a component that re-rendered three times on every keystroke last week.

First hypothesis: the parent was re-rendering on each state change. I asked Claude to look at the parent. It agreed — confirmed parent re-renders, here’s a fix wrapping the parent in React.memo.

I opened React DevTools Profiler. The parent wasn’t re-rendering at all. The child was re-rendering on its own — because a useEffect inside it had an inline object as a dependency:

useEffect(() => {
  fetchData(filter);
}, [{ filter, sort }]);

That object is recreated on every render. The effect “depends on” a brand new reference every time, so it fires every render, which sets state, which re-renders the child. Classic React footgun.

Me: Parent’s not rendering — check the profiler. The child has a useEffect with an inline object in its deps. New reference every render.

Claude: You’re right. Sorry for the confusion. The cause is the inline object in the useEffect dependency array.

Moved on. Asked for the fix.

const Parent = React.memo(({ filter, sort, items }) => {
  // ...
});

The fix Claude wrote was wrapping the parent in React.memo. The hypothesis we had ruled out two messages earlier.

I corrected it again. “It’s the inline object inside the child’s useEffect, not the parent.”

Claude: Right, the inline object. Sorry.

Then it proposed the same fix. React.memo on the parent.

I /clear‘d the session. Started over. Pasted only the child component and the profiler screenshot. New session caught the inline-object dependency in the first answer and proposed the correct fix: pull the object out with useMemo, or destructure into primitive deps.

Why this happens

Look at what’s actually in the context window at the moment Claude proposes the wrong fix for the second time:

  1. System prompt with tool definitions (~10k tokens)
  2. My first message: “this component re-renders three times on every keystroke”
  3. Claude’s confident first hypothesis: parent re-render — explained over a paragraph
  4. My correction with the profiler output
  5. Claude’s “you’re right, it’s the inline object” — one sentence
  6. A few intermediate messages
  7. My request: “give me the fix”

Item 3 is still in the prompt. It’s longer than item 5. It’s more confidently argued than item 5. When the model generates the next answer, it weighs everything in the prior turns. The parent-re-render framing wins because there’s more of it.

The apology doesn’t delete the wrong answer. It adds a short retraction at the end of a long argument. The argument keeps winning.

You can think of it like this: in a long enough conversation, your correction is a sticky note attached to a 10-page memo. The model reads the whole memo every time. The sticky note doesn’t always get noticed.

Three signs you’re in a “you’re right” loop

  • You corrected the model and it agreed.
  • Two or more turns later, the original wrong claim resurfaces.
  • You correct again. It agrees again. It does it again.

When you see this pattern, don’t reason with it. Don’t write a longer correction. Don’t paste the same evidence twice. /clear. Start over with the corrected version as your starting prompt.

The reminder you’re tempted to write will just be one more short message in a long polluted history. The history already lost that argument once.

3. The token tax

Most developers using Claude Code today are on a subscription — Claude Pro, Claude Max. Your bill doesn’t grow at the end of the month. Instead, your weekly quota burns out faster than it should. You hit the rate limit on Wednesday afternoon and wait until Friday to keep working. If you’re on API access, the same pressure shows up as a growing invoice. Either way, you pay the same tax — just in a different currency.

Here’s where the tax comes from. Every Claude Code request includes, on every single turn:

  • The system prompt with MCP tool definitions (~10k tokens)
  • The full prior conversation
  • Your new message
  • The response it generates

Prompt caching helps for a few minutes if you’re sending similar prefixes. After the cache TTL expires, the full content is recomputed.

After 50 messages of about 500 tokens each, you’ve accumulated ~25k tokens of history. Plus ~10k of system prompt. Every new request you send ships 35k input tokens before you’ve even typed your actual question. On a subscription, those tokens come out of your weekly allowance. On API, they come out of your wallet.

A back-of-the-envelope calculation on Anthropic’s published pricing (Sonnet around $3 per million input tokens, Opus roughly 5× that):

Scenario Avg input per request 30 requests Tokens shipped Cost (API)
One long session, no /clear (Sonnet) ~35k ~1.05M 1.05M ~$3.15
/clear every 10 messages + notes on disk (Sonnet) ~15k ~450k 450k ~$1.35
Same work, all on Opus, one session ~35k ~1.05M 1.05M ~$15.75
Research on Opus → write to disk → execute on Sonnet mixed ~250k Opus + ~450k Sonnet ~700k ~$5.10

These are model calculations from Anthropic’s published pricing, not a billing screenshot. Plug your own usage in — the ratio is what matters. You can do the same work for less than half the token cost just by clearing the surface. You can do it with a better model for a third of the cost by splitting the workload.

If you’re on a subscription, divide your weekly token allowance by these numbers. The “Claude Max ran out by Wednesday” feeling has a real cause — and it’s not your workload, it’s your session length.

A long messy session quietly burns through your quota — or your wallet — in the background. The rate-limit message at 4pm Wednesday looks like a usage problem. It’s a hygiene problem.

The three rules

Rule 1 — /clear is hygiene, not panic

Treat /clear the way you treat closing a browser tab. It’s not failure. It’s not “starting over.” It’s normal.

Things I clear on:

  • Switching from backend to frontend (different mental model).
  • Switching from designing something to implementing it (different decision style).
  • After any “you’re right” loop, no matter how confidently the apology was phrased.
  • After a long debugging detour that didn’t pay off — clear, restart with what I learned, not with the failed attempts.
  • At the end of any session that lasted more than an hour.

If you have two parallel tasks, open two terminals, not two threads in one session. The model doesn’t have tabs. The window doesn’t.

The cost of clearing is zero. The cost of not clearing accumulates linearly in tokens and nonlinearly in quality.

Rule 2 — Context lives on disk, not in the window

The window is volatile. It gets polluted, it gets long, you /clear it, it’s gone.

The disk is durable. A markdown file you wrote yesterday is still there today, exactly as you left it.

So after every non-trivial research session, I write the conclusions out. A plan into plans/, an architecture decision into docs/adr/, a set of patterns into docs/component-patterns.md. The next session reads the file as ground truth. The previous chat history is not invited.

This is the most underrated habit in Claude Code, and it’s also the cheapest. A 2k-token markdown file in your context replaces a 25k-token conversation history that the model would otherwise be re-shipping on every turn.

A practical example: I keep a file that lists the patterns used in my codebase — what to do, what we’ve already tried and abandoned, what’s idiomatic here. Every Claude Code session that touches the frontend reads that file first. Without it, the model invents patterns from training data — usually fine, sometimes wrong, occasionally suggesting a component I built six months ago as if it’s a new idea. With it, the model is grounded in the codebase as it exists today.

Disk-based context also survives /clear. Window-based context doesn’t. That asymmetry is the whole game.

Rule 3 — Use the right model for the right phase

Opus is the deep, expensive model. Sonnet is the fast, cheap one. Opus is roughly 5× the price of Sonnet per token.

The instinct is to pick one and use it for everything. That instinct is wrong for two reasons:

  1. If you pick Opus for everything, you pay Opus rates for the long tail of formatting fixes, lint errors, file shuffling, and “rename this variable.” None of that benefits from Opus reasoning.
  2. If you pick Sonnet for everything, your research phase produces a plan that’s almost right, you implement it, you hit a wall, you back up, you re-research, you implement again. The cost is real but invisible — it’s the rework, not the line items on your bill.

The pattern that wins: research and plan on Opus, then write the plan to disk, then /clear and start a fresh Sonnet session that reads the plan.

In numbers: a research-heavy task done entirely on Opus might run around $15. The same task split — Opus for research, written to a markdown file, Sonnet reading the file and executing — runs around $5. And the execution is better, because the plan is correct on the first try.

The split takes one extra step: writing your conclusions to a file. That step pays for itself in the first task.

What I clear and what I keep

To make this concrete, here is what I throw away and what I keep across the boundary of a /clear:

Thrown away (in the window):

  • The chronological back-and-forth.
  • All the wrong hypotheses I explored.
  • Claude’s apologies for being wrong.
  • Long tool outputs I needed once.

Kept (on disk):

  • The final correct plan.
  • The architecture decision with the reasoning.
  • The patterns I want the next session to know about.
  • Any “do not do X again” notes from this session.

When I open a new session, the window starts at zero. The disk hands me a clean version of what I learned. The two together are stronger than any long conversation.

Closing

You don’t get better answers from Claude by holding a longer conversation with it. You get better answers by managing the surface it works on — what’s in it, what’s gone from it, and which model is reading it now.

The long session feels productive because the model still talks back. But it’s slowly losing the thread, weighing wrong hypotheses against your corrections, and burning through your quota — or your wallet — for the privilege.

/clear. Write things to disk. Switch models on purpose. Treat the context window like a workbench, not a journal.

Context isn’t memory. It’s a working surface. Keep it clean.

I shipped my first agentic workflow on GitHub Actions — 4 AI reviewers on every PR

Four review-agent comments on PR #43, each showing the 429 Too Many Requests fallback message

I run Khetisahayak — a small agritech app — solo. Every PR I open has nobody to review it but me, and I review my own code about as well as anyone reviews their own code. So I built a GitHub Actions workflow that drops four AI reviewers and a test runner onto every PR. This is the first real “agentic” workflow I’ve shipped, and it now greets every commit I push with five separate opinions.

This post walks through exactly how it works — trigger, sequence, and flow — plus what I’d change.

Here’s what one PR actually looks like in production (this is the fallback path when the Gemini API rate-limits us — more on that at the end):

Four review-agent comments on PR #43, each showing the 429 Too Many Requests fallback message

Four bot comments, one per persona — Senior Developer, CSO, Engineering Lead, Software Architect — and a fifth from the test runner. When the AI call succeeds, those “Automated review could not be completed” blocks are replaced with actual line-by-line review feedback.

The two files that do everything

The whole system is two files:

  • .github/workflows/code-review-agents.yml — the GitHub Actions workflow (the orchestration)
  • .github/scripts/review_agent.py — a ~120-line Python script that calls Gemini once per reviewer persona

That’s it. No Lambda, no queue, no separate review service. GitHub Actions is the runtime.

1. The trigger

on:
  pull_request:
    branches: [main, develop]
    types: [opened, synchronize, reopened]

Fires when a PR targeting main or develop is:

  • opened — PR created
  • synchronize — new commits pushed to the PR branch
  • reopened — closed PR reopened

It does not fire on direct pushes to main, draft PRs (until marked ready), PRs targeting other branches, comments, or label changes. That’s deliberate — I don’t want a reviewer firing every time someone reacts with a 👍.

Permissions are scoped tight:

permissions:
  contents: read
  pull-requests: write
  issues: write

Read the code, write comments. Nothing else.

2. The sequence — five jobs, one DAG

The workflow defines five jobs. GitHub Actions runs them according to needs: declarations:

flowchart TD
    A([PR event]) --> B[L1: peer-review]
    A --> D[L4: security-review]
    A --> E[test-execution]
    B --> C[L2: lead-review]
    C --> F[L3: architect-review]
    B --> G[review-gate]
    C --> G
    F --> G
    D --> G
    E --> G

    style B fill:#dbeafe,stroke:#2563eb
    style C fill:#dbeafe,stroke:#2563eb
    style F fill:#dbeafe,stroke:#2563eb
    style D fill:#fee2e2,stroke:#dc2626
    style E fill:#dcfce7,stroke:#16a34a
    style G fill:#f3f4f6,stroke:#6b7280

Three things kick off in parallel the moment a PR event lands:

  1. peer-review (L1) — code style, correctness, DRY
  2. security-review (L4) — OWASP, secrets, injection, auth
  3. test-execution — actually runs Jest + Flutter tests in a real Postgres container

A sequential chain runs alongside:

  • lead-review waits on peer-review finishing
  • architect-review waits on lead-review finishing

The final gate waits on all five:

review-gate:
  needs: [peer-review, lead-review, architect-review, security-review, test-execution]
  if: always()

if: always() means the gate runs even if upstream jobs fail. Right now it just echoes each job’s result — it doesn’t actually block the merge. To make it blocking, you add a step that exits non-zero on any failure, then mark review-gate as a required check in branch protection. (I have not done this yet. Don’t be like me.)

Observable wall-clock timing on a typical PR:

  • T=0: peer + security + tests start
  • T≈45s: peer finishes → lead starts
  • T≈90s: lead finishes → architect starts
  • T≈3–8 min: tests finish (usually the slowest)
  • T≈4–9 min: review-gate fires

End-to-end: roughly 5–10 minutes per PR, with 4 AI comments + 1 test report appearing on the PR.

3. The flow inside one review job

Every AI review job is the same 4-step pattern. Here’s peer-review as the template:

peer-review:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
      with: { fetch-depth: 0 }

    - name: Get PR diff
      env: { GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} }
      run: |
        gh pr diff ${{ github.event.pull_request.number }} > /tmp/pr_diff.txt
        head -c 30000 /tmp/pr_diff.txt > /tmp/pr_diff_truncated.txt

    - uses: actions/setup-python@v6
      with: { python-version: '3.11' }

    - name: Run Peer Review
      env:
        GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
        PR_TITLE: ${{ github.event.pull_request.title }}
        PR_BODY: ${{ github.event.pull_request.body }}
        PR_NUMBER: ${{ github.event.pull_request.number }}
      run: python3 .github/scripts/review_agent.py peer

    - name: Post Peer Review Comment
      env: { GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} }
      run: |
        gh pr comment ${{ github.event.pull_request.number }} 
          --repo ${{ github.repository }} 
          --body-file /tmp/review_peer.md

Step by step:

  1. Checkout — full clone (fetch-depth: 0). Honestly overkill since we use gh pr diff which hits the GitHub API, not local git. A shallow clone would be fine.
  2. Get the diff — gh pr diff <num> writes the diff to disk, then head -c 30000 truncates to fit the model’s context window.
  3. Run the Python agent — picks the right persona prompt, posts to Gemini, writes a markdown file.
  4. Post the comment — gh pr comment --body-file drops the markdown onto the PR.

The four reviewer jobs are nearly identical — only the persona argument changes (peer, lead, architect, security).

The Python script — one file, four personas

PROMPTS = {
    "peer": {
        "role": "Senior Developer (Peer Reviewer)",
        "emoji": "👨‍💻",
        "focus": (
            "You are a senior developer doing a peer code review.n"
            "Focus on:n"
            "1. Code correctness — logic bugs, off-by-one, null checksn"
            "2. Code style — naming, readability, commentsn"
            "3. DRY violationsn"
            "4. Error handlingn"
            "5. Edge casesn"
            "Rate: APPROVE, REQUEST_CHANGES, or COMMENT."
        ),
    },
    "lead":      { "role": "Engineering Lead",          "focus": "..." },
    "architect": { "role": "Software Architect",        "focus": "..." },
    "security":  { "role": "Chief Security Officer",    "focus": "..." },
}

prompt = (
    f"{config['focus']}nn"
    f"PR #{PR_NUMBER}: {PR_TITLE}n"
    f"Description: {PR_BODY}nn"
    f"Diff:n```
{% endraw %}
n{diff_text[:25000]}n
{% raw %}
```nn"
    f"Respond in markdown. Start with your verdict, then list findings."
)

url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={API_KEY}"
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=60) as r:
    output = json.loads(r.read())["candidates"][0]["content"]["parts"][0]["text"]

No SDK, no framework. Stdlib urllib, one POST to Gemini, write the response to /tmp/review_<level>.md. The whole script is ~120 lines.

The security persona prompt is the most useful to look at, because it’s the one that has actually caught things:

You are a security officer reviewing this PR for vulnerabilities.
Check for:
1. OWASP Top 10 — SQL injection, XSS, CSRF, broken auth, SSRF
2. Secrets — hardcoded API keys, tokens, passwords, connection strings
3. Input validation — unsanitized user input, path traversal
4. Authentication/Authorization — missing auth checks, privilege escalation
5. Data exposure — PII leaks in logs, overly permissive CORS
6. Dependency risk — known CVEs in added dependencies
7. Cryptography — weak algorithms, insecure random generation
For each finding, provide: severity (Critical/High/Medium/Low), location, and fix.

It once flagged a JWT_SECRET I’d inlined into a .env.example “for convenience.” Worth the API spend by itself.

4. The flow inside the test-execution job

The test job has a different shape — it actually runs code instead of asking an LLM about code:

test-execution:
  services:
    postgres:
      image: postgres:14
      env:
        POSTGRES_USER: test
        POSTGRES_PASSWORD: test
        POSTGRES_DB: kheti_sahayak_test
      ports: ['5432:5432']
      options: >-
        --health-cmd pg_isready
        --health-interval 10s
        --health-timeout 5s
        --health-retries 5

  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with: { node-version: '20.x' }
    - uses: subosito/flutter-action@v2
      with: { flutter-version: '3.29.0', channel: 'stable', cache: true }

    - name: Run Backend Tests
      continue-on-error: true
      working-directory: kheti_sahayak_backend
      env:
        DATABASE_URL: postgresql://test:test@localhost:5432/kheti_sahayak_test
      run: |
        npm install
        npm test -- --coverage --json --outputFile=/tmp/backend_test_results.json

Two things worth calling out:

  • Real Postgres in a service container — no SQLite-in-memory shortcut. The backend tests hit a real DB, the same major version as prod.
  • continue-on-error: true on each suite — if Jest fails, Flutter tests still run. The job ends with a markdown report that shows ✅/❌ per suite and gets posted as a comment.

5. The gotchas (and the 429 in that screenshot)

Now the part I’d actually change.

1. The sequential L1→L2→L3 chain wastes time. Nothing in the lead prompt actually uses peer-review output — each reviewer reads the same diff independently. Running all four review jobs in parallel would cut wall time roughly 3×. The chain made sense when I imagined later reviewers reading earlier reviewers’ comments. They don’t.

2. review-gate doesn’t actually gate anything. It just echoes results. Branch protection has to do the real enforcement. Easy to miss.

3. Every job re-runs gh pr diff. Cheap but wasteful. A single setup job that uploads the diff as an artifact would be cleaner.

4. fetch-depth: 0 on every job is overkill. gh pr diff is an API call.

5. The 429 in the screenshot up top. Four jobs hitting Gemini within ~30 seconds of each other, free tier, no backoff. The Python script catches the exception and writes the “Automated review could not be completed” message you see in the screenshot — so the workflow stays green, but the PR ends up with four useless comments. Two fixes I’m considering: add a small retry-with-jittered-backoff in the script, and stagger the jobs (security can wait 60s; it’s not on the critical path).

Why I built it this way

I wanted four opinions, not one synthesized one. When a real team reviews a PR, the security person notices different things than the architect. Combining the personas into one prompt blurs that. Splitting them into separate jobs costs me a few extra API calls but gives me four distinct comment threads on the PR — which is exactly the shape a human reviewer cluster would have.

Total cost: roughly $0.01 per PR at Gemini 2.0 Flash prices, plus GitHub Actions minutes (free for me on a personal repo). Cheaper than coffee, more thorough than me reviewing my own PR at 11pm.

The files, in full

If you want to drop this into your own repo:

  • Put the workflow at .github/workflows/code-review-agents.yml
  • Put the script at .github/scripts/review_agent.py
  • Add GEMINI_API_KEY to your repo’s Actions secrets
  • Open a PR

That’s the whole setup. No external service, no webhook, no infrastructure.

If you build something on top of this, I’d love to hear what you change — especially how you handle the rate-limit problem and whether you ever managed to get one reviewer to actually read another’s comments.