NPM Archaeology: 5 Years in the Ground, Still Breathing

Let me set the scene: It’s a Saturday. Nothing particularly dramatic is happening. I’m just sitting there, scrolling through my old GitHub repos kinda digital archaeologist, and I find it
— react-calendar-datetime.
My old and abandoned calendar package.
Version 1.3.1 Last update: five years ago. ~100 downloads a month. A couple of themes. A moment.js dependency that probably weighs more than the entire package should.

I don’t know what came over me. Maybe it was lazy weekend. Maybe it was nostalgia. Maybe I just wanted to feel something. Either way, I opened it up and thought: let’s see how bad this is.

State of Things

Honestly? It wasn’t that bad. The core worked. The calendar rendered dates correctly and didn’t catch fire when you clicked on it. By the standards of “abandoned weekend projects”, this was actually in pretty good shape.

But I cannot look at working code and leave it alone. I wanted to try to optimize it, pour in everything I’d learned over the past 5 years — and if that didn’t work out, at least break something 😀

So what started as “let me just poke around” turned into a two-week sprint that took the package from v1.3.1 all the way to v3. I have no one to blame but myself.

The First Boss: TypeScript

The first real challenge was migrating to TypeScript. I’d been putting this off for years — not because I don’t like TypeScript (I really do), but because migrating an existing JS project to TS is exactly as fun as it sounds. You think you’re just adding types. You are not just adding types. You start finding every lazy shortcut you ever took with your data, all at once, while the compiler just stares at you and judges.

It took a while. But once it was done, everything felt more solid. Like the project had grown a spine.

The Dependency Problem (Or: My Obsession With Bundle Size)

Here’s something I didn’t expect to become a personality trait: I got absolutely fixated on bundle size.
It started innocently. The original package had moment.js. Classic. I migrated to dayjs, felt very smart about it, moved on. Then I started actually measuring the bundle.

Then I couldn’t stop.

Then I started resenting every single byte that wasn’t mine.
So I removed dayjs too 😀
Wrote my own date utilities. They’re not fancy — no one’s going to write a blog post about my addMonths function — but they work, they’re pretty small, and they belong to me.

Final score: zero external dependencies. Bundle stayed around: ~5-7kb gzipped. I check this number more often than I’d like to admit.

The CSS Saga

I moved all styles from SCSS to goober, a CSS-in-JS library. Felt modern. Felt good. 1kb of injected code. Shipped it.

But I couldn’t stop thinking about zero dependency library.

And what we have now: CSS Modules. No more goober. I slept better after this decision and I’m not joking. At least for next few weeks 😀

The Localization Rabbit Hole

This is my favorite part of the whole story, because it went from “annoying problem” to “surprisingly elegant solution” in a way that felt like cheating.
Originally, locales were only supported for presets — “Today”, “Yesterday”, etc. and just for few languages.
Then I added dayjs locale support for month names, weeks…
This was… fine, but the list of supported locales was limited, and every new locale added weight to the bundle. (Remember my obsession?)

The more languages I wanted to support, the fatter the package got.

It was a trap.

Then I discovered Intl — the native browser internationalization API. And I just… used that instead.

400+ locales. Month names, weekday names, relative time formatting for presets — all of it, natively, with zero extra bytes. No dictionaries. No JSON files. No dependencies.

I just sat back and stared at the ceiling for a moment. Sometimes the platform just gives you what you need and you’ve been ignoring it.

The Theme System (My Proudest Weird Thing)

At some point I started making themes. Then I made more themes. Then I couldn’t stop making themes. The package now has 18 of them — dark, light, brutalist, pastel, neon — and I love each one like a slightly chaotic parent.

But more importantly: trying to manage all these themes efficiently (bundle size!) pushed me to design a proper theming system based on CSS variables.
Seven variables per theme, one lookup table, predictable output. That system ended up being clean enough that I extracted it into a separate npm package

We all need more NPM packages 😀

That’s just what happens when you get bored on a weekend.

The Easy Win I Didn’t Expect

Migrating from rollup to tsup, and later to tsdown — I expected this to be a nightmare. Build tooling migrations have a reputation, and it’s not a good one.
It was fine. It was suspiciously fine. The authors of these tools did a great job and I got to just… configure a few things and move on. After weeks of one thing leading to another, this was a nice surprise.

Where Things Stand

Two weeks of obsessive weekend coding later:

v3.1.2 is out

  • Fully modular: toggle time picker, month grid, year selector, presets independently
  • 18 themes with a CSS variable system, gradients, shades.
  • 400+ locales via Intl
  • Week numbers, weekend highlighting, swipe gestures, jelly mode (don’t ask, just try it)
  • ~5.9kb gzipped
  • Accessibility work in progress (there’s always more to do here, I know)

Downloads went from 100/month to… more than 100/month. I’m not going to oversell it. But people are using it, filing issues, and occasionally saying nice things, which is more than I had before.

The Honest Conclusion

Open source has always made me nervous — I’ve tried before and it never really clicked. This time feels different, maybe because I’m not trying to make something important. I’m just trying to make something good.
If you have an old project collecting dust somewhere: maybe don’t let it sit for five years. Or do.

Get bored on a Saturday.

See what happens.
The package is react-calendar-datetime if you want to take a look. PRs welcome. Issues welcome. Gentle feedback about my themes collection absolutely welcome.

How to send sensor data to a gateway using PainlessMesh in ESP8266?

Hello, I’m Ganesh. I’m building git-lrc, an AI code reviewer that runs on every commit. It is free, unlimited, and source-available on Github. Star Us to help devs discover the project. Do give it a try and share your feedback for improving the product.

In this article, I will be demonstrating how to use PainlessMesh with a NodeMCUv2 (ESP8266) board and send data from one ESP8266 board to another ESP8266 board.

What Problem we are solving?

Previously we explored how to use MQTT with ESP8266.

Now let’s integrate painlessmesh with MQTT.

Let’s assume we have 2 ESP8266 boards.
One is gateway and another is node.

We want to send data from node to gateway using painlessmesh. Next we want to send data from gateway to cloud using MQTT.

For example we will use DHT11 sensor and mq series gas sensor with node to send data to gateway.

This operation should be done with very minimal data transfer.

If one node has 2 or more sensors it should be compressed and sent to gateway.

What We will be achieving?

Painlessmesh is very heavy library. It does lot of operations to keep network alive. With this if we send data without compressing it. It will consume lot of power and data. Casuing network to be unstable.

Main Goal is

  1. Receive data from multiple sensors
  2. Compress the data
  3. Send the data to gateway
  4. Publish the data from received at gateway to cloud using MQTT
  5. Scallable MQTT topics publishing

I will be combining all the above mentioned in single project.

In this article we will be covering first 2 points.

  1. Receiving Data from Multiple Sensors
  2. Compressing the data
  3. Send the data to gateway

Receiving Data from Multiple Sensors and Compressing it

For this example we will use DHT11 sensor and MQ2 gas sensor.

  1. Setup connection with painlessmesh
    We will use these credentials for painlessmesh.
#define MESH_PREFIX "secreat_name"
#define MESH_PASSWORD "SecreateKey"
#define MESH_PORT 5555

With this funtion we create scheduler and painlessmesh object.

#include <painlessMesh.h>

  mesh.init(MESH_PREFIX, MESH_PASSWORD, &scheduler, MESH_PORT);
  mesh.onReceive(&onReceive);
  mesh.onNewConnection(&onNewConnection);
  1. Setup connection with DHT11 sensor

We use DHT library sensor to measure temperature and humidity.

We use D2 pin to connect DHT11 sensor.

#include <DHT.h>
#define DHT_PIN D2
#define DHT_TYPE DHT11

DHT dht(DHT_PIN, DHT_TYPE);

Read the temperature and humidity from the sensor.

float t = dht.readTemperature();
float h = dht.readHumidity();

As Dht sensor should have 1 second delay between readings. We will use scheduler to read the temperature and humidity from the sensor.

Task readDHTTask(10000, TASK_FOREVER, []() {
    float t = dht.readTemperature();
    float h = dht.readHumidity();
    if (isnan(t) || isnan(h)) {
        Serial.println("Failed to read from DHT sensor!");
        return;
    }
    Serial.printf("Temperature: %.2f C, Humidity: %.2f %%", t, h);
});
  1. Setup connection with MQ2 gas sensor

We use MQ2 gas sensor to measure the gas concentration.

We use A0 pin to connect MQ2 gas sensor.

#define MQ2_PIN A0

Read the gas concentration from the sensor.

float gas = analogRead(MQ2_PIN);

As MQ2 sensor should have 1 second delay between readings. We will use scheduler to read the gas concentration from the sensor.

Task readMQ2Task(10000, TASK_FOREVER, []() {
    float gas = analogRead(MQ2_PIN);
    if (isnan(gas)) {
        Serial.println("Failed to read from MQ2 sensor!");
        return;
    }
    Serial.printf("Gas: %.2f ppm", gas);
});
  1. Compress the data with bit packing

We use bit packing to compress the data.

As sensor will be in specified range we can use bit packing to compress the data.

String packToHex(float temp, float hum, float gas) {
  uint16_t packedTemp = (uint16_t)(temp * 10.0f) & 0x1FF;         // 9 bits
  uint16_t packedHum = (uint16_t)((hum - 20.0f) * 10.0f) & 0x3FF; // 10 bits
  uint16_t packedGas = (uint16_t)(gas / 10.0f) & 0x3FF;           // 10 bits

  uint32_t packed = 0;
  packed |= (uint32_t)packedTemp << 20; // bits 28-20
  packed |= (uint32_t)packedHum << 10;  // bits 19-10
  packed |= (uint32_t)packedGas;        // bits  9-0

  char buf[9];
  snprintf(buf, sizeof(buf), "%08X", packed);
  return String(buf);
}

Finaly commbining all these into one code.

/**
 * sensor_node_1.cpp — PainlessMesh Sensor Node (DHT11 + MQ6)
 *
 * Boot flow:
 *   1. Joins the mesh network
 *   2. On first connection to gateway → broadcasts /config (packing schema)
 *      The gateway forwards /config to MQTT → backend caches the schema
 *   3. After 3 s delay → starts sending /data every 5 s
 *      (gives backend time to process /config before first hex payload arrives)
 *
 * /alert is sent immediately any time gas > GAS_THRESHOLD.
 *
 * Bit packing layout (29 bits used, 3 MSBs unused):
 *   bits 28-20 (9 bits) : temp  = temp_celsius * 10        (0–511 → 0–51.1°C)
 *   bits 19-10 (10 bits): hum   = (humidity - 20) * 10     (0–1023 → 20–122.3%)
 *   bits  9-0  (10 bits): gas   = gas_ppm / 10             (0–1023 → 0–10230
 * ppm)
 */

#include <Arduino.h>
#include <ArduinoJson.h>
#include <DHT.h>
#include <painlessMesh.h>

// ─── Mesh credentials (must match gateway) ───────────────────────────────────
#define MESH_PREFIX "secreat_name"
#define MESH_PASSWORD "SecreateKey"
#define MESH_PORT 5555

// ─── Sensor pins ─────────────────────────────────────────────────────────────
#define DHT_PIN D2
#define DHT_TYPE DHT11
#define MQ6_PIN A0 // analog pin

// ─── Alert threshold ─────────────────────────────────────────────────────────
#define GAS_THRESHOLD 5000 // ppm — triggers /alert

// ─── Timing ──────────────────────────────────────────────────────────────────
#define DATA_INTERVAL_MS 5000

// ─── Globals ─────────────────────────────────────────────────────────────────
Scheduler scheduler;
painlessMesh mesh;
DHT dht(DHT_PIN, DHT_TYPE);
bool configSent = false; // send /config once per gateway connection

// ─── Bit packing ─────────────────────────────────────────────────────────────

/**
 * Pack temperature, humidity and gas into an 8-char hex string.
 * The encoding matches what the backend's unpackHex() expects.
 *
 *   temp  → 9 bits, mult 10, min 0
 *   hum   → 10 bits, mult 10, min 20
 *   gas   → 10 bits, div 10, min 0
 */
String packToHex(float temp, float hum, float gas) {
  uint16_t packedTemp = (uint16_t)(temp * 10.0f) & 0x1FF;         // 9 bits
  uint16_t packedHum = (uint16_t)((hum - 20.0f) * 10.0f) & 0x3FF; // 10 bits
  uint16_t packedGas = (uint16_t)(gas / 10.0f) & 0x3FF;           // 10 bits

  uint32_t packed = 0;
  packed |= (uint32_t)packedTemp << 20; // bits 28-20
  packed |= (uint32_t)packedHum << 10;  // bits 19-10
  packed |= (uint32_t)packedGas;        // bits  9-0

  char buf[9];
  snprintf(buf, sizeof(buf), "%08X", packed);
  return String(buf);
}

// ─── Message builders
// ─────────────────────────────────────────────────────────

/**
 * Build the /config JSON that tells the backend how to decode our hex payloads.
 * This matches the NodeConfig struct on the backend.
 */
String buildConfigMsg() {
  JsonDocument doc;
  doc["type"] = "config";
  doc["hw"] = "NodeMCUv2";

  JsonObject packing = doc["packing"].to<JsonObject>();
  packing["bits"] = 32;
  JsonArray layout = packing["layout"].to<JsonArray>();
  layout.add("temp");
  layout.add("hum");
  layout.add("gas");

  JsonObject sensors = doc["sensors"].to<JsonObject>();
  JsonObject t = sensors["temp"].to<JsonObject>();
  t["model"] = "DHT11";
  t["metric"] = "temperature";
  t["min"] = 0;
  t["max"] = 50;
  t["mult"] = 10;
  t["bits"] = 9;

  JsonObject h = sensors["hum"].to<JsonObject>();
  h["model"] = "DHT11";
  h["metric"] = "humidity";
  h["min"] = 20;
  h["max"] = 90;
  h["mult"] = 10;
  h["bits"] = 10;

  JsonObject g = sensors["gas"].to<JsonObject>();
  g["model"] = "MQ6";
  g["metric"] = "gas_mq6";
  g["min"] = 0;
  g["max"] = 10000;
  g["div"] = 10;
  g["bits"] = 10;

  String out;
  serializeJson(doc, out);
  return out;
}

String buildDataMsg(const String &hexStr) {
  JsonDocument doc;
  doc["type"] = "data";
  doc["d"] = hexStr;
  String out;
  serializeJson(doc, out);
  return out;
}

String buildAlertMsg(const String &hexStr, const char *cause) {
  JsonDocument doc;
  doc["type"] = "alert";
  doc["cause"] = cause;
  doc["d"] = hexStr;
  String out;
  serializeJson(doc, out);
  return out;
}

// ─── Periodic data task
// ────────────────────────────────────────────────────────

Task dataTask(DATA_INTERVAL_MS, TASK_FOREVER, []() {
  float temp = dht.readTemperature();
  float hum = dht.readHumidity();

  // MQ6: raw analog 0-1023 → scale to ppm (rough calibration, adjust to yours)
  int raw = analogRead(MQ6_PIN);
  float gas = raw * (10000.0f / 1023.0f); // map 0-1023 → 0-10000 ppm

  if (isnan(temp) || isnan(hum)) {
    Serial.println("[sensor] DHT read failed");
    return;
  }

  Serial.printf("[sensor] temp=%.1f°C  hum=%.1f%%  gas=%.0f ppmn", temp, hum,
                gas);

  String hexStr = packToHex(temp, hum, gas);

  // Always send /data
  mesh.sendBroadcast(buildDataMsg(hexStr));

  // Send /alert if threshold breached
  if (gas > GAS_THRESHOLD) {
    Serial.println("[sensor] ⚠ Gas threshold breached — sending alert");
    mesh.sendBroadcast(buildAlertMsg(hexStr, "gas_mq6"));
  }
});

// One-shot task: fires 3 s after /config is sent, then enables dataTask.
// The 3 s gives the gateway time to publish /config to MQTT and the backend
// time to cache the packing schema before the first hex payload arrives.
Task startupTask(3000, 1, []() {
  Serial.println("[sensor] Config settle time elapsed — starting data stream");
  scheduler.addTask(dataTask);
  dataTask.enable();
});

// ─── Mesh callbacks
// ────────────────────────────────────────────────────────────
void onReceive(uint32_t from, String &msg) {
  Serial.printf("[mesh] From %u: %sn", from, msg.c_str());
  // Future: handle /cmd messages from gateway
}

void onNewConnection(size_t nodeId) {
  Serial.printf("[mesh] +Connected to %un", (uint32_t)nodeId);

  if (!configSent) {
    // First connection — push /config so backend can cache packing schema
    mesh.sendBroadcast(buildConfigMsg());
    Serial.println("[mesh] Sent /config — waiting 3 s before data stream");
    configSent = true;

    // Start the 3-second countdown before data begins
    scheduler.addTask(startupTask);
    startupTask.enable();
  }
}

// ─── Setup / Loop
// ─────────────────────────────────────────────────────────────
void setup() {
  Serial.begin(115200);
  delay(100);

  dht.begin();

  mesh.setDebugMsgTypes(ERROR | CONNECTION);
  mesh.setContainsRoot(
      true); // tells node the root (gateway) is in this network
  mesh.init(MESH_PREFIX, MESH_PASSWORD, &scheduler, MESH_PORT);
  mesh.onReceive(&onReceive);
  mesh.onNewConnection(&onNewConnection);

  Serial.printf("[mesh] Node ID: %u — waiting for gateway connection...n",
                mesh.getNodeId());
}

void loop() { mesh.update(); }

By above code we can send data from multiple sensors to gateway using painlessmesh.

Conclution

In this article we have seen how to use painlessmesh with a NodeMCUv2 (ESP8266) board and send data from one ESP8266 board to another ESP8266 board.

In next article we will integrate scallable gateway.

git-lrc

Any feedback or contributors are welcome! It’s online, source-available, and ready for anyone to use.
⭐ Star it on GitHub: https://github.com/HexmosTech/git-lrc

The OpenClaw Problem, or: How I Stopped Wrapping Docker in VMs and Built a One-Command Setup Instead

GitHub logo

A continuation of 128GB of RAM, Zero Internet, and a Year of Building AI Infrastructure Nobody Asked For

When I shipped headless agent mode for cloister, I thought I’d solved the hard problem. VM isolation, consent policies, credential forwarding — all the security layers you’d want between an autonomous AI agent and your actual life. The next step was obvious: run OpenClaw inside it.

OpenClaw is an open-source AI agent framework — a persistent assistant that connects to Telegram, WhatsApp, iMessage, and runs tasks on your behalf. It has a gateway (the brain), nodes (execution endpoints), and a sandbox for running arbitrary code. It’s powerful. It’s also the kind of software whose community has been refreshingly honest about: don’t run it on bare metal.

So I didn’t. I ran it inside a cloister VM. Inside Docker. Inside a Linux VM. On macOS.

If that sentence made you wince, you’re ahead of where I was.

Three Security Bypasses and a Caddy Proxy

The first sign something was architecturally wrong came when I tried to access OpenClaw’s Control UI through an SSH tunnel. Three things broke, each requiring a security bypass:

  1. CSP Header. OpenClaw’s CSP hashes didn’t match its actual inline scripts. The UI rendered as a blank page. Fix: a Caddy reverse proxy to strip and replace the header. Not ideal.

  2. Device Auth. Docker NAT meant the gateway saw 172.18.x.x instead of 127.0.0.1, triggering device pairing over HTTPS — which SSH tunnels don’t provide. Fix: dangerouslyDisableDeviceAuth: true. The flag’s name judged me every time I opened the config.

  3. Gateway Bind. Loopback binding + Docker port publishing don’t mix. Fix: gateway.bind: lan, which is exactly what the security docs tell you not to do.

I rationalized all three: “The VM has three isolation layers. Even with bypasses, the blast radius is contained.” Technically true. Also a Rube Goldberg machine where each layer existed to compensate for the layer below it.

Then I found OpenClaw’s Lume documentation.

The “Wait, They Already Solved This” Moment

OpenClaw has a docs page titled “macOS VM.” I’d skimmed past it. When I actually read it:

Lume runs macOS VMs on Apple’s Virtualization.framework — same hypervisor tech cloister uses through Colima for Linux, but with macOS guests. The key differences that made me rethink everything:

  • No Docker. OpenClaw installs natively. It has its own sandbox — the one I was redundantly wrapping in another Docker layer.
  • No CSP bypass. Gateway runs on actual localhost. No NAT, no proxy.
  • No auth bypass. Connections come from real localhost. Device pairing just works.
  • No bind hack. Gateway stays on loopback. Everything works as documented.
  • iMessage access. Lume VMs are real macOS — they can run BlueBubbles for iMessage bridging. Linux VMs can’t. Ever.

Five problems solved by removing a layer of abstraction. I chose the option that meant admitting the layer I’d built was wrong: abstract the VM backend so cloister supports both. Colima for Linux workloads, Lume for macOS. The user says --openclaw, cloister picks Lume. The user creates a regular profile, cloister picks Colima. Nobody needs to know which hypervisor is underneath.

The Installation Gauntlet

If you’ve tried setting up a Lume macOS VM yourself, you’ve probably already rage-quit once. Here’s what actually happens:

The IPSW download. Lume creates a macOS VM from Apple’s ~18GB restore image. It downloads to a temp directory. If the setup fails — and it will — the download is gone. Next attempt? Another 35 minutes on a decent connection. I built IPSW caching into cloister so it downloads once, keeps it at ~/.cloister/cache/ipsw/, and reuses it across every attempt and every profile. The kind of feature nobody thanks you for until they don’t have it.

The version mismatch. Apple’s Virtualization.framework refuses to install a macOS guest newer than the host. It downloads the 18GB IPSW, starts the install, and rejects it after 25 minutes. I added a preflight check that parses the IPSW filename version, compares against sw_vers, and fails in five seconds instead of twenty-five minutes.

The Setup Assistant. Lume automates the macOS Setup Assistant by clicking through 167 UI elements via VNC. On macOS 26.4, Apple renamed a button. Step 66 of 167 fails. Every time. This is an upstream Lume issue — if you’re doing it yourself, you’ll need to create the VM without --unattended and click through setup manually via VNC. Five minutes by hand. Not ideal, but you only do it once per base image.

The base image. cloister solves this with a shared base image that every OpenClaw profile clones from. First profile: 15-20 minutes. Every subsequent one: about 2 minutes. Plus snapshots — factory (after provisioning) and user (your fully configured state) — so cloister reset gets you back to working in under two minutes.

If you’re setting this up without cloister, budget an afternoon and a decent internet connection. Or save yourself the trouble.

The Real Problem: Nobody Knows Where to Start

The Lume setup gets you a blank macOS VM. Congratulations. Now you need to:

  1. Configure passwordless sudo
  2. Install Xcode Command Line Tools, Homebrew, Node.js, Playwright, OpenClaw
  3. Run the OpenClaw daemon onboard with the right flags
  4. Set up a Telegram bot via BotFather
  5. Figure out your Telegram user ID (hint: message @userinfobot)
  6. Configure Ollama as a provider — but first figure out the VM’s bridge gateway IP
  7. Set up Google OAuth for Gmail, Calendar, Drive — but the OAuth callback goes to localhost, and you’re inside a VM, so you need an SSH tunnel
  8. Deal with the macOS keychain password that drifted from the user password during headless setup
  9. Register the node host with OpenClaw’s device pairing system
  10. Approve the device — but not with --all because that flag doesn’t exist, it’s --latest

Each of these has at least one gotcha that’ll cost you 30 minutes of Googling. Some have gotchas that’ll cost you a day.

I know because I hit every single one of them.

cloister setup openclaw

So I built a wizard.

cloister create --openclaw my-oc    # Create the Lume VM (clones from base image)
cloister setup openclaw my-oc       # One command. Handles everything.

The wizard runs five sections in order. Each one writes config and credentials immediately — never batches at the end. If your WiFi drops halfway through, re-running the wizard picks up from the last completed step.

Credentials. Detects 1Password CLI. If found, stores everything there with Touch ID. If not, asks if you want to install it or use local encrypted storage. Generates a new keychain password (because the headless setup almost certainly left it in a state where the old one doesn’t work), stores it before applying it to the VM (credential sync invariant — never change a password you haven’t recorded), and stores the VM user credentials.

Channels. Walks you through BotFather for Telegram — or accepts an existing bot token. Collects your user ID with instructions to message @userinfobot. Writes the config with dmPolicy: allowlist and allowFrom locked to your Telegram ID. I initially used allowedUserIds because that’s what seemed logical. OpenClaw rejected it. The actual field is allowFrom. The kind of thing where you stare at a “Config invalid” error for twenty minutes before realizing the docs and the schema disagree.

WhatsApp gets configured as an action-only channel — OpenClaw can send you notifications, but nobody can issue commands through it. Safe defaults: allowCommands: false, allowFrom locked to your number, escalationPolicy: deny. Because the last thing you want is someone in a WhatsApp group accidentally telling your AI agent to rm -rf /.

Providers. Auto-detects Ollama on the VM’s bridge gateway IP. Lists available models. Offers to pull qwen3:32b if nothing’s loaded. Then asks the real question: local inference (free, fan sounds like a jet engine) or Anthropic Claude (costs money, quiet). Both can be registered simultaneously — you just pick a default. The wizard handles the API key collection and 1Password storage.

Google OAuth. This one was fun. gog (the Google CLI) needs to run inside the VM, but the OAuth callback redirects to localhost — which is the VM’s localhost, not your Mac’s. The wizard picks a random high port (49152–60999, collision-checked), sets up an SSH tunnel, runs gog auth add with --listen-addr 0.0.0.0:<port>, and the callback routes through the tunnel back to gog. You just paste the URL in your browser and click Allow.

Except gog’s file-backed keyring needs a password to encrypt tokens, and there’s no TTY available for the prompt. The wizard sets GOG_KEYRING_PASSWORD to the keychain password it already stored. Another 45 minutes of debugging, now a single environment variable in the setup flow.

Device Pairing. Registers the node host, writes trusted proxy config, approves pending devices, and runs openclaw gateway probe to verify the whole thing is actually healthy — WebSocket connectivity, RPC health, auth warnings. Not just “is the process running” but “can we actually talk to it.”

The whole flow takes about 3 minutes on a running VM. Non-interactive mode works too:

cloister setup openclaw my-oc 
  --telegram-token="BOT_TOKEN" 
  --telegram-user-id="USER_ID" 
  --default-provider=ollama 
  --ollama-model=qwen3:32b 
  --google-client-secret=~/client_secret.json 
  --google-email="[email protected]"

And for AI agents that want to discover what’s configurable:

cloister setup openclaw my-oc --list-options --json

The Config Serialization Bug That Almost Ate Everything

While building the wizard, I discovered that older versions of cloister (the one installed via Homebrew) would silently drop the backend: lume field when saving config. Go’s yaml.v3 library ignores struct fields it doesn’t recognize during unmarshal, so the old binary would load the config, not understand backend, and write it back without it. Next time the new binary loaded the config, it would default to Colima and everything would break.

The fix was three things: remove omitempty from the Backend field (always serialize it), add a config version number (so we can detect and migrate), and rotate config.yaml to config.yaml.prev before every save. If anything goes wrong, your previous config is one file rename away. Simple, boring, correct.

The Agent Command That Had to Die

The original cloister had a whole agent subcommand tree — agent start, agent stop, agent status, agent logs, agent forward. This made sense when OpenClaw ran inside Docker inside a Linux VM. The VM was one thing, the Docker container was another, and you needed separate controls for each.

With Lume, the VM is the agent. OpenClaw runs as a launchd service, not a Docker container. There’s no separate lifecycle to manage. So I replaced the entire agent subcommand tree with unified profile commands: cloister status shows everything (both backends, VM state, gateway health), cloister logs tails the gateway log, cloister stop stops the VM. One interface, regardless of what’s running underneath.

The old cloister agent command now prints a polite deprecation message pointing to the new commands. Because breaking people’s muscle memory without a breadcrumb trail is rude.

What I Learned (Again)

When I hit the Docker networking problems, my instinct was to add complexity. A proxy for the CSP. A flag to disable auth. A bind override. Each solution was individually defensible. Together, they formed a tower of compensating controls that existed only because the foundational decision — running a macOS-native application inside Docker inside Linux inside macOS — was wrong.

The Lume approach doesn’t need any of those fixes because it doesn’t create any of those problems.

Removing abstraction layers is harder than adding them. You have to admit the layer you built was wrong. You have to resist the voice that says “but it works fine with the three workarounds.” It does work fine. But when you’re building infrastructure that autonomous AI agents run on, “fine” is measured in security bypasses you hope nobody exploits.

The setup wizard is the part I’m most satisfied with. Not because the code is clever — it’s mostly prompts, SSH commands, and state tracking — but because it encodes every gotcha, every undocumented field name, every TTY password issue, every keychain drift problem into a flow that takes 3 minutes instead of a day. The next person who wants to run OpenClaw in an isolated macOS VM doesn’t need to learn any of this. They just run one command.

That’s what infrastructure is supposed to do: absorb complexity so the user doesn’t have to.

GitHub logo

ekovshilovsky
/
cloister

Run multiple Claude Code accounts & AI agents on one Mac — isolated macOS VMs with secure sandboxing

Cloister: Isolated VMs for AI Agents & Multi-Account Claude Code on macOS

CI
Release
Go Report Card
License: MIT

Isolated macOS VM environments for running multiple Claude Code organizations, sandboxing autonomous AI agents like OpenClaw, and separating credentials across client engagements.

cloister demo

Why cloister?

Multi-account isolation. Claude Code stores credentials, conversation history, and project config in ~/.claude. If you work across multiple organizations, every session shares the same identity. cloister gives each account its own isolated VM with separate credentials, CLAUDE.md, and conversation history.

Autonomous agent containment. AI agents like OpenClaw run 24/7 with shell access, browser control, and cron scheduling. cloister’s VM isolation is stronger than Docker (separate kernel, not just namespace isolation) — services inside the VM are unreachable from the host unless explicitly tunneled, and cloister stop is an instant kill switch.

Dual-backend architecture. Colima (Linux VMs) for Claude Code isolation and Docker workloads. Lume (macOS VMs via Apple Virtualization Framework) for OpenClaw…

cloister — Isolated VM environments for AI coding agents and multi-account separation. Dual-backend: Colima (Linux) and Lume (macOS). One-command OpenClaw setup.

op-forward — Forward 1Password CLI across SSH boundaries with biometric authentication.

5 Alternatives to OpenClaw If You Need Enterprise-Grade Security

OpenClaw is impressive. It’s also not built for teams with SSO requirements, audit logs and data isolation mandates. Here’s what to use instead.

Let me save you three weeks of evaluation time.

You heard about OpenClaw. You watched the demo videos. A local AI agent framework that runs entirely on your infrastructure, no data leaving your environment, full tool use, open source, it looked like exactly what your team needed. Then someone from your security team asked a few questions and the enthusiasm in the room dropped noticeably.

Where are the audit logs? How does it handle SSO? What’s the data isolation model between users? Can it integrate with our existing RBAC implementation? Is there a SOC 2 report?

These are not unreasonable questions. They’re the standard questions that any enterprise security team asks about any new piece of infrastructure. And the honest answer for OpenClaw in its current form is: these capabilities either don’t exist, require significant custom engineering to implement, or are on the roadmap without committed timelines.

This is not a criticism of OpenClaw. It’s a framework built by developers for developers, optimised for capability demonstration and local deployment flexibility. It does what it says on the tin. What it says on the tin is not “enterprise-grade security-compliant agent infrastructure.”

If you need that, here’s what to evaluate instead, with an honest comparison of where each option fits.

What “Enterprise-Grade Security” Actually Means Here

Before the alternatives, it helps to be precise about what we’re evaluating against. When enterprise security teams evaluate agent platforms, the requirements cluster around five areas:

Authentication and access control — SSO integration (SAML 2.0, OIDC), MFA enforcement, role-based access control with granular permissions, service account management.

Audit logging — immutable, queryable logs of every agent action, tool call, data access and user interaction. Exportable to SIEM systems. Retention policies that meet compliance requirements.

Data isolation — tenant-level data separation, configurable data residency, encryption at rest and in transit with customer-managed keys where required.

Security compliance — SOC 2 Type II certification (or equivalent), penetration testing recency, vulnerability disclosure programme, SLA for security patches.

Network and deployment security — VPC deployment support, private endpoints, air-gapped deployment options for highly regulated environments.

OpenClaw scores poorly on most of these out of the box. The alternatives below score well on most of them, with different trade-offs in capability, cost and deployment complexity.

For a detailed technical breakdown of what OpenClaw is and how its self-hosted architecture works before you make a comparison decision, the Dextra Labs explainer covers the architecture in detail.

The Comparison Table

Now let’s go through each one properly.

1. LangChain Enterprise (LangSmith + LangServe)

Best for: Teams already using LangChain who need to move from prototype to production with audit capability
LangChain’s open-source framework is where most teams start building agents. LangChain Enterprise adds the production and compliance layer on top, LangSmith for observability, tracing and audit logging, LangServe for deployment with authentication and the enterprise support tier for the compliance documentation your procurement team needs.

The security story:
LangSmith’s tracing infrastructure captures every LLM call, tool invocation, input, output and intermediate step in a queryable audit log. For a compliance team asking “what did your AI agent do and why,” LangSmith gives you the evidence trail. SSO via SAML 2.0 and OIDC is supported. Role-based access control is granular enough for most enterprise requirements.

pythonfrom langchain import hub
from langchain.agents import AgentExecutor, create_react_agent
from langchain_anthropic import ChatAnthropic
from langsmith import Client

import os
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_API_KEY"] = "your_langsmith_key"
os.environ["LANGCHAIN_PROJECT"] = "enterprise-agent-prod"

# Every action below is automatically traced
# and logged to LangSmith with full audit trail
llm = ChatAnthropic(model="claude-sonnet-4-5")

tools = [search_tool, calculator_tool, db_query_tool]
prompt = hub.pull("hwchase17/react")

agent = create_react_agent(llm, tools, prompt)
agent_executor = AgentExecutor(
    agent=agent, 
    tools=tools,
    verbose=True,
    # Return intermediate steps for audit log
    return_intermediate_steps=True,
    # Handle parsing errors gracefully
    handle_parsing_errors=True
)

result = agent_executor.invoke({
    "input": "Analyse Q3 customer churn and identify top 3 factors"
})

# Full trace available in LangSmith dashboard
# including every tool call and LLM reasoning step

Where it falls short: Self-hosting LangSmith Enterprise is possible but operationally complex. If your requirement is air-gapped deployment with no external dependencies, the setup effort is significant. The open-source LangSmith self-hosted version exists but lacks the enterprise security features.

Pricing: Contact sales. Roughly $500-2000/month depending on trace volume and team size.

2. Google Vertex AI Agent Builder

Best for: Teams on GCP who need the strongest possible compliance posture and are comfortable with a managed service
Vertex AI’s agent infrastructure inherits Google Cloud’s compliance certifications, SOC 2, ISO 27001, HIPAA, FedRAMP (at higher tiers) and its security model. If your compliance requirements include any of the major certifications and you’re already on GCP, this is the path of least resistance.

The security story:
Data never leaves your GCP project. VPC Service Controls can isolate your agent infrastructure from the public internet entirely. Cloud Audit Logs captures every API call with immutable records exportable to Chronicle or your SIEM. IAM integration handles SSO, MFA and fine-grained permissions via Google Workspace or your external IdP.

pythonfrom google.cloud import aiplatform
from vertexai.preview import reasoning_engines

# Initialize with your project's security configuration
aiplatform.init(
    project="your-gcp-project",
    location="us-central1",
    # All data stays within your GCP project
    # VPC SC controls apply automatically
)

# Define agent with tool use
class EnterpriseAgent(reasoning_engines.Queryable):
    def query(self, input: str) -> str:
        # Agent logic here
        # All interactions logged to Cloud Audit Logs
        pass

# Deploy to managed infrastructure
# Inherits GCP SOC 2 / ISO 27001 compliance
agent = reasoning_engines.ReasoningEngine.create(
    EnterpriseAgent(),
    requirements=["anthropic>=0.20.0"],
    display_name="enterprise-agent-prod"
)

response = agent.query(input="Summarise open support tickets")

Where it falls short: GCP lock-in is real. If your infrastructure is multi-cloud or on-premises, Vertex AI’s compliance story doesn’t extend outside GCP. The agent builder is also less flexible than open frameworks, custom tool integrations require more boilerplate.

Pricing: Usage-based. Typically $0.025-0.05 per 1K characters for the agent layer, plus underlying model costs.

3. Microsoft AutoGen Enterprise

Best for: Teams in Microsoft-heavy environments, Azure, M365, Entra ID, who need multi-agent workflows with enterprise auth
AutoGen’s multi-agent framework is genuinely powerful for complex workflows where multiple specialised agents collaborate. The enterprise version adds Azure AD (Entra ID) integration, which means SSO is essentially free if you’re already on the Microsoft stack and audit logging via Azure Monitor.

The security story:
Entra ID integration handles authentication, MFA and conditional access policies automatically. Azure Monitor captures agent interactions with Log Analytics workspaces for compliance reporting. Data residency is configurable to your Azure region. SOC 2 and ISO 27001 coverage through Azure’s compliance certifications.

pythonimport autogen
from autogen import AssistantAgent, UserProxyAgent, GroupChat

# Configure with Azure OpenAI or Anthropic via Azure
# All traffic stays within your Azure tenant
config_list = [
    {
        "model": "gpt-4o",
        "api_type": "azure",
        "api_base": "https://your-resource.openai.azure.com/",
        "api_version": "2024-02-01",
        # Auth handled by Azure AD managed identity
        # No secrets in config
        "azure_ad_token_provider": "DEFAULT"
    }
]

# Define specialised agents for enterprise workflow
code_reviewer = AssistantAgent(
    name="code_reviewer",
    system_message="Review code for security vulnerabilities and standards compliance.",
    llm_config={"config_list": config_list}
)

security_auditor = AssistantAgent(
    name="security_auditor", 
    system_message="Assess security posture and compliance requirements.",
    llm_config={"config_list": config_list}
)

orchestrator = UserProxyAgent(
    name="orchestrator",
    human_input_mode="NEVER",
    code_execution_config={
        "work_dir": "sandbox",
        "use_docker": True  # Isolated execution
    }
)

# Multi-agent workflow with full Azure Monitor logging
groupchat = GroupChat(
    agents=[orchestrator, code_reviewer, security_auditor],
    messages=[],
    max_round=10
)

Where it falls short: AutoGen’s enterprise features are most complete when you’re using Azure OpenAI as the model provider. Using Anthropic or other providers requires more configuration and you lose some of the native Azure security integration benefits.

Pricing: AutoGen framework is open source. Enterprise support and Azure infrastructure costs apply separately.

4. Haystack Enterprise (deepset)

Best for: Teams building document-heavy agent workflows, RAG pipelines, document processing, knowledge base agents, who need self-hosting with a strong security model

Haystack is the most genuinely open-source option on this list from a security architecture perspective. deepset’s enterprise offering adds the compliance layer on top of the open-source framework, but the framework itself is auditable in a way that matters to security teams who need to understand what their software is doing.

The security story:

Haystack Enterprise offers self-hosted deployment with full data isolation, your data never touches deepset’s infrastructure unless you choose the cloud offering. SSO via SAML 2.0 and OIDC. Audit logging at the pipeline level. SOC 2 Type II certified. The architecture is pipeline-based, which makes it easier to implement and audit data flow controls than more opaque agent frameworks.

pythonfrom haystack import Pipeline
from haystack.components.generators import AnthropicGenerator
from haystack.components.retrievers import InMemoryBMRetriever
from haystack.components.builders import RAGPromptBuilder
from haystack.logging import configure_logging

# Configure enterprise logging
# Outputs to your SIEM via structured JSON
configure_logging(
    level="INFO",
    format="json",
    include_metadata=True,     # Captures user context
    include_component_trace=True,  # Full pipeline trace
    output="stdout"            # Capture by your log aggregator
)

# Build auditable RAG agent pipeline
pipeline = Pipeline()

pipeline.add_component(
    "retriever", 
    InMemoryBMRetriever(document_store=document_store)
)
pipeline.add_component(
    "prompt_builder",
    RAGPromptBuilder(template=enterprise_template)
)
pipeline.add_component(
    "generator",
    AnthropicGenerator(model="claude-sonnet-4-5")
)

# Connect components — data flow is explicit and auditable
pipeline.connect("retriever.documents", "prompt_builder.documents")
pipeline.connect("prompt_builder.prompt", "generator.prompt")

# Run with user context for audit trail
result = pipeline.run(
    data={
        "retriever": {"query": user_query},
        "prompt_builder": {
            "query": user_query,
            "user_id": current_user.id,  # Audit context
            "session_id": session.id
        }
    }
)

Where it falls short: Haystack is optimised for pipeline-based workflows. If your use case requires complex multi-step autonomous agent behaviour, the ReAct-style loops that OpenClaw excels at, Haystack requires more custom work to implement.

Pricing: Open source framework. Enterprise support and cloud offering via deepset starting around $1,500/month.

5. CrewAI Enterprise

Best for: Teams who need multi-agent workflows with role-based agents and want the fastest path from prototype to production
CrewAI’s framework is the newest on this list and the most rapidly developing. The enterprise offering is also the newest, SOC 2 Type II certification is in progress rather than complete, which is worth flagging for teams with strict compliance requirements. That said, the security architecture is solid and the certification is expected to complete in 2026.

The security story:
SSO via SAML 2.0 and OIDC. Comprehensive audit logging of every agent action and crew interaction. Tenant-level data isolation in the cloud offering. Self-hosting available with a private deployment option. The role-based agent model maps naturally to enterprise organisational structures, a manager agent, a researcher agent, a writer agent, which makes access control design more intuitive.

pythonfrom crewai import Agent, Crew, Task, Process
from crewai.security import AuditLogger, AccessControl

# Configure enterprise security
audit_logger = AuditLogger(
    output="structured_json",
    include_inputs=True,
    include_outputs=True,
    include_tool_calls=True,
    retention_days=90  # Configurable for compliance
)

access_control = AccessControl(
    rbac_config="./config/rbac.yaml",
    sso_provider="okta",  # Or AzureAD, Google, etc.
    mfa_required=True
)

# Define agents with role-based access
analyst = Agent(
    role="Senior Data Analyst",
    goal="Analyse customer data and identify patterns",
    backstory="Expert in customer behaviour analysis",
    verbose=True,
    tools=[db_query_tool, analytics_tool],
    # Scoped tool access per role
    allowed_tools=["db_query", "analytics"],
    max_iter=5
)

reporter = Agent(
    role="Report Writer",
    goal="Produce executive-ready summaries",
    backstory="Specialises in clear business communication",
    tools=[document_tool],
    allowed_tools=["document_write"]
)

# Tasks with explicit data handling
analysis_task = Task(
    description="Analyse Q3 churn data. Focus on enterprise segment.",
    agent=analyst,
    expected_output="Structured analysis with top 5 churn factors",
    # Data classification for audit
    data_classification="confidential"
)

crew = Crew(
    agents=[analyst, reporter],
    tasks=[analysis_task, report_task],
    process=Process.sequential,
    audit_logger=audit_logger,
    access_control=access_control
)

result = crew.kickoff()

Where it falls short: The SOC 2 gap is real for teams with strict compliance timelines. If you need certification documentation for a procurement decision this quarter, CrewAI Enterprise isn’t ready yet. Check back in six months.
Pricing: Enterprise pricing on request. Community edition is open source and free.

How to Choose

The honest decision framework comes down to three questions.

What’s your infrastructure constraint? GCP-locked teams should look at Vertex AI first, the compliance story is the strongest and the integration with existing GCP security tooling is seamless. Azure-heavy teams should look at AutoGen Enterprise. Infra-agnostic teams with self-hosting requirements should look at LangChain Enterprise or Haystack Enterprise.

What’s your workflow type? Document-heavy RAG pipelines, Haystack. Multi-agent collaborative workflows, AutoGen or CrewAI. General-purpose agent with strong observability, LangChain Enterprise. Maximum compliance certification coverage, Vertex AI.

What’s your timeline? If you need SOC 2 documentation for a procurement decision this quarter, CrewAI is out. If you can wait six months and the workflow fit is strong, it’s worth reconsidering.

None of these are perfect OpenClaw replacements for the developer who wants full local control with zero external dependencies. That’s genuinely a trade-off, the security features that enterprise teams require add architectural complexity that pure local deployment can’t accommodate.

If you’re still evaluating whether OpenClaw might work for your use case with some custom security engineering or whether one of these alternatives is the right fit, the https://dextralabs.com/blog/top-openclaw-alternatives/ comparison from Dextra Labs covers the detailed feature matrix, pricing and deployment complexity for each option.

If you’re evaluating self-hosted agent platforms more broadly, Dextra Labs has a detailed breakdown of OpenClaw’s architecture and where it fits in the market including the specific security gaps and what it would take to close them with custom engineering.

Copilot CLI Weekly: MCP Servers Get LLM Access

MCP Sampling Lands in v1.0.13-0

The most significant change this week is buried in a prerelease tag: MCP servers can now request LLM inference. Version 1.0.13-0, released today, adds sampling support to the Model Context Protocol implementation. MCP servers can call the user’s LLM through a permission prompt, eliminating the need for servers to maintain their own API subscriptions.

This is a shift in how MCP servers work. Before this, an MCP server was a tool provider — it exposed functions the agent could call, but it couldn’t reason on its own. Now, with sampling, an MCP server can delegate reasoning back to the user’s LLM mid-execution. A recipe generator can ask the LLM to format output. A code analysis server can ask for natural language summaries. The user approves each request via a review prompt, maintaining control over what their LLM processes.

The feature has been in the MCP spec since VS Code shipped it last summer, but adoption has been slow. Copilot CLI supporting it means the entire GitHub-integrated toolchain now has access to this capability. If you’re building MCP servers, you can now lean on the user’s model instead of spinning up your own inference backend.

Model Picker Gets a Full Redesign

Version 1.0.12 landed March 26 with 28 improvements and fixes, but the UX standout is the full-screen model picker with inline reasoning effort controls. Previously, selecting a model and adjusting reasoning effort were separate steps. Now, the picker opens in full-screen mode, and you adjust reasoning effort with arrow keys (← / →) while browsing models.

The picker also reorganizes models into three tabs: Available, Blocked/Disabled, and Upgrade. This solves the confusion around which models are accessible based on your plan and organization policy. If you’re on a free tier and wondering why you can’t select opus-4.6, the picker now tells you explicitly instead of silently blocking the selection.

The reasoning effort level also displays in the header next to the model name (e.g., claude-sonnet-4.5 (high)), so you always know your current configuration without running a command.

Organization Policy Enforcement and Memory Fixes

v1.0.12 also included critical stability work. The CLI no longer crashes with out-of-memory errors when shell commands produce high-volume output — a real problem when running builds or test suites that generate megabytes of logs. Memory usage improvements extend to the grep tool, which now handles large files without exhausting available RAM.

Version 1.0.11, released March 23, brought organization-level policy enforcement for third-party MCP servers. If your organization uses an MCP allowlist, that policy now applies universally. Blocked servers no longer show up in /mcp show, and the CLI displays a warning when policy blocks a server from loading. This matters for enterprise deployments where security teams need to audit what external tools can access organizational data.

Skills Directory Alignment and Hook Improvements

v1.0.11 also aligned the personal skills directory with VS Code’s GitHub Copilot for Agents extension. The CLI now discovers skills in ~/.agents/skills/, matching the default used by the VS Code extension. If you’ve been maintaining separate skill directories for the CLI and the extension, you can consolidate them.

Extension hooks from multiple extensions now merge instead of overwriting each other. Previously, if two extensions defined a sessionStart hook, only one would fire. Now, both execute, and the additionalContext from sessionStart hooks is injected into the conversation. That’s critical for building custom agents that layer multiple extension behaviors.

The /yolo Command Gets More Precise

The /allow-all command (aliased as /yolo) now supports subcommands: /yolo on, /yolo off, and /yolo show. This replaces the previous toggle behavior with explicit enable/disable semantics, reducing the risk of accidentally leaving permission-free mode enabled. The CLI also persists /yolo path permissions across /clear session resets, so you don’t have to re-approve directories after clearing context.

What Else Shipped

The other notable fixes from v1.0.12:

  • Workspace MCP servers defined in .mcp.json now load correctly when your working directory is the git root
  • Sessions with active work are no longer cleaned up by the stale session reaper (a frustrating bug if you left a session idle mid-task)
  • Resume session now correctly restores your previously selected custom agent
  • Clipboard copy works on Windows even when a non-system clip.exe is in PATH
  • Emoji selection in the terminal now works correctly (yes, this matters)

Version 1.0.13-0 added a handful of additional fixes, including correct reasoning effort handling for Bring Your Own Model (BYOM) providers and better error messaging when using classic Personal Access Tokens.

The Platform Is Taking Shape

Three releases in seven days, with one major capability (MCP sampling), a redesigned model picker, enterprise policy enforcement, and dozens of stability fixes. The pattern I’ve been tracking since the biggest week yet continues: this isn’t a standalone tool anymore. It’s a platform. MCP servers can now request inference, extensions can inject context into subagents, and the SDK keeps expanding with hooks and custom commands.

The CLI is becoming the runtime for a growing ecosystem of agent tooling. If you’re building developer tools and haven’t looked at integrating with it, this is the week that makes the case.

Hey Dev.to, I’m Bitrus

I’ll keep the intro short , I’m a full-stack developer from Nigeria, I go by CRAN3 – Honestly its my Call of duty username and basically most of my socials but that’s a story for another day, I just shipped something I actually want to talk about.

So here’s the thing about me and DSA
I struggled with it. Not because I wasn’t trying I actually was. But reading “Binary Search is O(log N)” over and over did nothing for me. I needed to see it happen. I’m just wired that way.
So instead of fighting it, I leaned into it and built AlgoTracker.

What it is
A visual DSA tool. You pick an algorithm, throw in some data, and watch it work in real time, step by step.
It covers Linear and Binary Search, Selection and Insertion Sort, a step visualizer, and my personal favourite which is the time complexity graph that actually runs each algorithm and plots how long it takes based on input size. Seeing O(log N) stay flat while O(N²) shoots up was the moment DSA finally made sense to me.
Built with React, Tailwind CSS v4, and Recharts.
I’m here to build in public and connect with people doing the same. Say hi
https://algo-tracker-vu7t.vercel.app/
https://github.com/Cr4N31



)



How Linux is Used in Real-World Data Engineering

Linux is the backbone of modern data engineering. From running ETL pipelines on cloud servers to managing distributed systems like Hadoop and Spark, proficiency with the Linux command line is non‑negotiable. In this guide, we’ll walk through a realistic data‑engineering workflow on an Ubuntu server – the kind of tasks you’ll perform daily when managing data pipelines, securing sensitive files, and organising project assets.

We’ll cover:

  1. Secure login to a remote server
  2. Structuring a data project with version‑aware directories
  3. Creating and manipulating data files (CSV, logs, scripts)
  4. Copying, moving, renaming, and cleaning up files
  5. Setting correct permissions to protect sensitive data
  6. Navigating the file system and re‑using command history

1. Logging into a Linux Server

In the real world, data engineers rarely work on their local laptop. Most tasks happen on remote servers (on‑premises or in the cloud). The first step is to securely connect to it using SSH, and put in the password when prompted.

After login it, should look like this

Once logged in, it’s good practice to confirm you are using the correct account. Data pipelines often run under dedicated service accounts, so knowing your user context matters.

whoami      # displays the current username

Next, verify your current working directory, which should indicate where you will start creating folders and files using the command pwd

pwd          # prints the current working directory

2. Folder and File Creation

A well‑organised directory structure is vital for any data project. Let’s create a main folder named after ourselves and inside it create subfolders for raw data, processed data, logs, and scripts.
We will then confirm our folders were created using ls

mkdir ~/fredrickMDataEngineering   # make a new folder
cd ~/fredrickMDataEngineering  # enter the folder
mkdir raw_data processed_data logs scripts # make multiple folders
ls   # check current working directory content

Now we need to create files that simulate real data engineering assets. Inside each folder, create the appropriate csv files using the “touch” command:

# Raw data
touch raw_data.csv

# Processed data
touch cleaned_data.csv

# Logs
touch logs.csv

# Scripts
touch scripts.csv

3. File Operations: Copy, Move, Rename, Delete

Data engineering involves frequent file manipulation, backing up raw data, moving files between stages, versioning assets, and cleaning up obsolete files.

Lets back by copying the raw CSV file as a precaution before processing using the cp command

cp raw_data.csv raw_data_backup.csv

Lets move a file to simulate data flow:

mv cleaned_data.csv logs/

Rename the processed file to indicate a version:

mv raw_data/sample_data.csv raw_data/sample_data_v1.csv

Deleting unnecessary files:

rm raw_data/raw_data_backup.csv
  1. Managing Permissions for Security
    In production, sensitive files (e.g., credentials, raw PII data) must have strict permissions. Here’s how we secure our project.

Lets make the main directory accessible only by its owner (no one else can read, write, or execute). We use the “-R” flag to make the command affect all files and folders in the curent working directory

chmod 700 -R ~/fredrickMDataEngineering  

We will also set permissions for sensitive files using this command

chmod 600 raw_data/raw_data.csv 

To confirm if our permissions are set, use this command in the current working directory.

ls -l  

Our ETL script needs execute permission to run, we will use this command to make the script executable

chmod +x scripts/scripts.csv

7. Navigation and Command History

Data engineers constantly move between directories. Use relative and absolute paths to navigate:

cd ~/fMwangiDataEngineering/scripts   # go to scripts folder
cd ../logs                               # move back to logs
cd ~                                    # back to home

Hidden files (e.g., .env for environment variables) are common in data projects. View them with:

ls -a

Your terminal history is a goldmine, it helps reproduce exact commands, and track actions within the terminal. We view it with this command:

history

To re‑run a previous command (e.g., command number 2104), use

!2104

8. Why These Skills Matter

What you just practised is a miniature version of real‑world data engineering:

  1. Structured folders mirror how data lakes or data warehouses are organised.
  2. File operations (copy, move, rename) simulate the stages of an ETL pipeline, from ingestion to transformation to archiving.
  3. Permissions protect sensitive data and ensure only authorised users (or processes) can modify critical files.
  4. Scripts automate repetitive tasks, and command history allows you to audit or replay steps.

GenHTTP vs ASP.NET Minimal APIs: The C# Benchmark Showdown Nobody Expected

Two C# frameworks walk into a benchmark. One is the industry standard backed by Microsoft. The other is a scrappy indie framework most .NET developers have never heard of. You’d expect a blowout — and you’d be right. You just might be wrong about who gets blown out where.

HttpArena recently added both GenHTTP and ASP.NET Minimal APIs to their benchmark suite, and the results tell a story that’s way more interesting than “Microsoft wins.” Let’s dig in.

The Contenders

ASP.NET Minimal APIs needs no introduction. It’s Microsoft’s lightweight API framework running on Kestrel, the battle-tested HTTP server that powers half the internet’s .NET workloads. Minimal APIs strip away controllers and give you app.MapGet("/route", handler) — clean, fast, no ceremony.

GenHTTP is a modular, embeddable C# web server that runs its own HTTP engine. It layers on abstractions — layouts, services, concerns, resource methods — giving you a higher-level programming model. Think “convention over configuration” but for HTTP servers.

Both target .NET 10. Both speak C#. Same runtime, same GC, same JIT. So any performance delta comes down to the framework itself.

Let the games begin.

Round 1: Baseline — The 10x Gap Nobody Expected

Concurrency GenHTTP ASP.NET Minimal
512 49,507 req/s 495,831 req/s
4,096 48,551 req/s 459,783 req/s
16,384 22,169 req/s 353,394 req/s

Yeah. Read that again. ASP.NET is doing 10x the requests per second on a simple GET-with-query-params endpoint.

This is where GenHTTP pays the tax for its abstraction layer. Look at the code — ASP.NET’s handler is a direct IResult return. GenHTTP routes through a LayoutBuilder, resolves a ServiceResource, matches a ResourceMethod attribute, and wraps things in a Concern pipeline. Every request walks through that entire abstraction stack.

Is it elegant? Sure. Is it 350,000 requests per second worth of overhead? Also yes.

To be fair, ~50K req/s is still plenty fast for most real applications. But if your use case is “handle a simple request as fast as physically possible,” ASP.NET Minimal isn’t even trying and it’s lapping GenHTTP.

Round 2: Pipelined — Wait, They’re Tied?

Concurrency GenHTTP ASP.NET Minimal
512 12,349,888 req/s 12,790,124 req/s
4,096 13,148,857 req/s 13,748,303 req/s
16,384 10,999,584 req/s 12,741,100 req/s

Thirteen. Million. Requests per second. From both of them.

The pipelined test is the great equalizer. When you shove requests down a persistent connection as fast as TCP allows, you’re basically benchmarking the I/O layer, not the framework. And GenHTTP, despite its abstraction overhead, keeps pace with ASP.NET within ~15%.

This tells us something important: GenHTTP has solid I/O fundamentals. The bottleneck in baseline isn’t bad networking code — it’s the request processing pipeline on top. Good bones, heavy coat.

Round 3: Upload — The 23GB Elephant in the Room

Concurrency GenHTTP (req/s / RAM) ASP.NET Minimal (req/s / RAM)
64 609 / 429MB 184 / 23.2GB
256 630 / 591MB 187 / 24.1GB
512 622 / 862MB 160 / 22.7GB

I need you to look at this table and understand what you’re seeing. ASP.NET Minimal is using twenty-three gigabytes of RAM to handle file uploads at 160 requests per second. GenHTTP handles nearly 4x the throughput while sipping under a gig.

What is happening here? Let’s look at the code.

ASP.NET’s upload handler:

public static async Task<IResult> Upload(HttpRequest req)
{
    using var ms = new MemoryStream();
    await req.Body.CopyToAsync(ms);
    return Results.Text(ms.Length.ToString());
}

It copies the entire request body into a MemoryStream. Every upload, fully buffered in RAM. With concurrent uploads, you’re stacking multi-megabyte buffers on top of each other. The GC is crying.

GenHTTP’s approach:

public ValueTask<long> Compute(Stream input)
{
    if (input.CanSeek)
        return ValueTask.FromResult(input.Length);
    return ComputeManually(input);
}

GenHTTP’s engine provides a seekable stream, so it just reads the length directly — zero buffering, zero copying. When running on Kestrel, it falls back to reading chunks through a small buffer. Either way, it’s dramatically more memory-efficient.

Now, is this an ASP.NET framework problem? Not exactly — it’s a handler implementation choice. You could write a streaming handler in ASP.NET. But the fact that the “obvious” way to handle uploads in Minimal APIs leads to 23GB of RAM usage is… not great. GenHTTP’s abstraction actually protects you from this footgun by giving you a smarter stream interface out of the box.

GenHTTP wins this round by a country mile, and it’s not even close.

Round 4: JSON Serialization — The Underdog Bites

Concurrency GenHTTP ASP.NET Minimal
4,096 582,510 req/s 515,135 req/s
16,384 561,453 req/s 440,034 req/s

Wait. The framework that was 10x slower on baseline is now beating ASP.NET on JSON serialization? By 13% at 4K connections and 28% at 16K?

The implementations are virtually identical — both deserialize a dataset, transform it, and serialize the response. Same System.Text.Json under the hood. The difference likely comes down to how each framework handles response writing and buffering for larger payloads. GenHTTP’s response pipeline may have less overhead when serializing structured data compared to ASP.NET’s Results.Json() wrapper.

This is where the “faster framework” narrative breaks down. Performance isn’t one number. It’s a profile.

Round 5: Compression — GenHTTP Keeps Winning

Concurrency GenHTTP ASP.NET Minimal
4,096 8,565 req/s 7,183 req/s
16,384 7,572 req/s 5,949 req/s

GenHTTP leads by 19-27% on compressed responses. Both use gzip at the fastest compression level, so the delta is in how they pipe compressed bytes to the wire. Another quiet win for the underdog.

Round 6: Noisy Neighbors — Dead Heat

Concurrency GenHTTP ASP.NET Minimal
512 1,080,435 req/s 1,110,339 req/s
4,096 1,218,298 req/s 1,210,574 req/s
16,384 1,017,992 req/s 1,007,141 req/s

Under CPU contention with background noise, they trade punches within 3%. At a million requests per second each, both frameworks handle stress gracefully. No complaints from either corner.

Round 7: Mixed Workload & Database — GenHTTP’s Sweet Spot

Test GenHTTP ASP.NET Minimal
Mixed 4,096c 7,962 req/s 6,650 req/s
Mixed 16,384c 9,269 req/s 6,240 req/s
Async DB 512c 138,179 req/s 122,812 req/s
Async DB 1,024c 161,488 req/s 147,924 req/s

GenHTTP pulls ahead by 20-49% on mixed workloads and 9-12% on async database queries. For realistic API workloads that juggle multiple endpoint types and database access — you know, actual applications — GenHTTP holds its own and then some.

Round 8: HTTP/2 and HTTP/3 — GenHTTP Sits This One Out

Test ASP.NET Minimal
H2 Baseline 256c 254,371 req/s
H2 Baseline 1,024c 203,325 req/s
H3 Baseline 256c 41,922 req/s

ASP.NET supports HTTP/2 and HTTP/3 out of the box via Kestrel’s QUIC integration. GenHTTP doesn’t participate in these tests — it’s HTTP/1.1 only for now. If you need modern protocol support, that’s a clear win for ASP.NET.

The Verdict: It’s Complicated (Obviously)

If you looked at the baseline numbers alone, you’d conclude ASP.NET Minimal is in a different league. And for raw request routing throughput, it is.

But real applications don’t just route requests. They serialize JSON, compress responses, handle file uploads, query databases, and juggle mixed workloads. And in those scenarios, GenHTTP frequently wins — sometimes by significant margins.

Choose ASP.NET Minimal if:

  • Raw request throughput is critical
  • You need HTTP/2 or HTTP/3 support
  • You want the massive .NET ecosystem and tooling
  • You’re building at scale and need Microsoft’s backing

Choose GenHTTP if:

  • You want a lightweight, embeddable server
  • Memory efficiency matters (looking at you, upload numbers)
  • Your workload is JSON-heavy, database-driven, or mixed
  • You appreciate a higher-level API that handles footguns for you

The most interesting takeaway? A small indie framework with a handful of contributors is genuinely competitive with — and sometimes faster than — one of the most optimized web stacks on the planet. That’s impressive engineering from the GenHTTP team.

Performance isn’t a single number. It’s a conversation. And this one just got a lot more interesting.

All benchmarks from HttpArena — an open-source, reproducible HTTP benchmark suite. Full source code and methodology available on GitHub.

👮🏻‍♂️ JWT Logout: What “Stateless” Really Means in Production

JWT authentication is often introduced with one powerful idea:

It is stateless.

That sounds great for scalability, but it leads to a very practical question every backend engineer faces:

How do you log out a user if there is no session on the server?

Let’s break this down clearly and practically.

🔑 First, the Core Idea

A JWT (JSON Web Token) is stateless, meaning:

  • The server does NOT store session data
  • Once issued, the token is valid until it expires
  • The server only verifies signature and expiry

👉 So technically:

❗ You cannot directly “log out” a JWT like a session

This is the key mindset shift. Logout is no longer about deleting something on the server.

🤔 Then What Does Logout Mean?

In JWT-based systems, logout becomes:

Preventing further use of an already issued token

There is no single built-in solution. Instead, we use design patterns with different trade-offs.

🧠 Practical Strategies

1️⃣ 🔥 Client-Side Logout (Most Common Starting Point)

How it works

  • Remove token from client storage (localStorage, cookies, memory)
localStorage.removeItem("accessToken")

What happens

  • Client stops sending the token
  • User appears logged out

But

  • Token is still valid on the server until expiry

👉 This is called soft logout

✔ Simple
❌ Not secure for sensitive systems

2️⃣ ⏳ Short-Lived Access Tokens + Refresh Tokens (Industry Standard)

How it works

  • Access token → short expiry (for example 10 to 15 minutes)
  • Refresh token → long-lived and stored securely (DB or HTTP-only cookie)

Logout flow

  • Delete or invalidate the refresh token
  • Access token expires naturally

👉 This is the most widely used approach in production systems

✔ Secure
✔ Scalable
✔ Balanced design

3️⃣ 🚫 Token Blacklisting (Server-Side Control)

How it works

  • Store invalidated tokens or token IDs in DB or Redis
blacklist = ["jwt-id-123", "jwt-id-456"]
  • On every request, check if the token is blacklisted

Logout flow

  • Add token to blacklist

👉 This introduces state into the system

✔ Immediate logout
❌ Extra lookup on every request
❌ More operational complexity

4️⃣ 🔄 Token Versioning (Clean and Scalable Trick)

How it works

  • Store a tokenVersion per user in the database
  • Include this version inside the JWT
{
  "userId": 1,
  "tokenVersion": 2
}

Logout flow

  • Increment tokenVersion in DB

On request

  • Compare token version with DB value
  • If mismatch, reject the token

✔ Efficient
✔ No need to track every token
❌ Requires a DB read

🧠 Real-World Summary

Approach Stateless Secure Used in production
Client delete only ✅ ❌ Rare (low-risk apps)
Short-lived + refresh ⚠️ partially ✅✅ ✅ MOST COMMON
Blacklist ❌ ✅ Sometimes
Token versioning ❌ ✅ Yes

⚖️ What Senior Engineers Actually Do

A common mistake is trying to keep systems “purely stateless”.

In reality:

  • Security requires control
  • Control introduces some form of state

So instead of chasing statelessness, experienced engineers focus on:

  • Minimizing state
  • Containing it where necessary
  • Keeping the system scalable

🧾 Final Takeaway

  • JWT does not provide logout out of the box
  • Logout is a system design decision
  • The most practical approach is:

    • Short-lived access tokens
    • Controlled refresh tokens
    • Optional revocation mechanisms when needed

Stateless authentication is a powerful concept, but real-world systems are always a balance between stateless design and controlled state.

🧠 Interview-Ready Answer

If you want to explain this concisely:

JWTs are stateless, so logout cannot be handled directly on the server. In practice, we use short-lived access tokens with refresh tokens. On logout, we invalidate the refresh token and let the access token expire. For immediate revocation, we can use token blacklisting or versioning, which introduces some state.

This is one of those topics where understanding trade-offs matters more than memorizing definitions. That is what separates implementation-level knowledge from system design thinking.

Happy system designing !!

AI Agent Orchestration: LangGraph, Temporal & Custom Workflows (2026 Guide)

A single AI agent with a few tools can handle simple tasks. But real-world workflows — process a customer refund, generate a research report, onboard a new user — involve multiple steps, conditional logic, parallel execution, error recovery, and human approvals.

    That's orchestration: the layer that coordinates what your agent does, in what order, and what happens when things go wrong. Without it, your agent is a talented freelancer with no project management. With it, your agent becomes a reliable workflow engine.

    This guide covers three approaches to agent orchestration: LangGraph (graph-based), Temporal (durable workflows), and custom orchestrators — with trade-offs and code for each.

    ## Why Orchestration Matters

    Consider a simple task: "Process a refund for order #12345."

    Without orchestration, your agent calls tools in whatever order the LLM decides. Sometimes it works. Sometimes it processes the refund before checking eligibility. Sometimes it retries a failed API call 47 times. Sometimes it forgets to send the confirmation email.

    With orchestration:
verify_identity → check_eligibility → [approve if > $100] → process_refund → send_confirmation
      │                    │                    │                   │
      └── retry 2x         └── if ineligible    └── timeout 5min   └── retry 3x
          then escalate        → explain why        → escalate         then log error
    Every step has defined behavior, error handling, and transitions. The workflow is testable, observable, and predictable.

    ## Approach 1: LangGraph — Graph-Based Orchestration

    LangGraph (from LangChain) models your agent as a **state machine graph**. Nodes are steps, edges are transitions, and state flows through the graph.

    ### Core Concepts


        - **State** — A typed dictionary that accumulates data through the graph
        - **Nodes** — Functions that read state, do work, and return updated state
        - **Edges** — Transitions between nodes (conditional or unconditional)
        - **Checkpointing** — Save state at each step for recovery and debugging


    ### Example: Customer Support Agent
from langgraph.graph import StateGraph, END
from typing import TypedDict, Literal

class SupportState(TypedDict):
    messages: list          # Conversation history
    intent: str             # Classified intent
    customer_id: str | None # Authenticated customer
    order: dict | None      # Looked-up order
    resolution: str | None  # How we resolved the issue
    should_escalate: bool

# Node functions
async def classify_intent(state: SupportState) -> SupportState:
    intent = await llm.classify(state["messages"][-1])
    return {"intent": intent}

async def authenticate(state: SupportState) -> SupportState:
    customer = await lookup_customer(state["messages"])
    return {"customer_id": customer["id"] if customer else None}

async def lookup_order(state: SupportState) -> SupportState:
    order = await get_order(state["customer_id"], state["messages"])
    return {"order": order}

async def generate_response(state: SupportState) -> SupportState:
    response = await llm.respond(state)
    return {"messages": [response], "resolution": "resolved"}

async def escalate(state: SupportState) -> SupportState:
    ticket = await create_support_ticket(state)
    return {"resolution": f"Escalated: {ticket['id']}"}

# Conditional edges
def route_after_classify(state: SupportState) -> Literal["authenticate", "respond", "escalate"]:
    if state["intent"] in ["order_status", "refund"]:
        return "authenticate"
    if state["intent"] == "general_question":
        return "respond"
    return "escalate"

def route_after_auth(state: SupportState) -> Literal["lookup_order", "escalate"]:
    if state["customer_id"]:
        return "lookup_order"
    return "escalate"

# Build the graph
graph = StateGraph(SupportState)

graph.add_node("classify", classify_intent)
graph.add_node("authenticate", authenticate)
graph.add_node("lookup_order", lookup_order)
graph.add_node("respond", generate_response)
graph.add_node("escalate", escalate)

graph.set_entry_point("classify")
graph.add_conditional_edges("classify", route_after_classify)
graph.add_conditional_edges("authenticate", route_after_auth)
graph.add_edge("lookup_order", "respond")
graph.add_edge("respond", END)
graph.add_edge("escalate", END)

# Compile with checkpointing
from langgraph.checkpoint.sqlite import SqliteSaver
checkpointer = SqliteSaver.from_conn_string("checkpoints.db")
app = graph.compile(checkpointer=checkpointer)

# Run
result = await app.ainvoke(
    {"messages": ["I want a refund for order #12345"], "should_escalate": False},
    config={"configurable": {"thread_id": "conv-123"}}
)
        **Tip:** LangGraph's checkpointing is its killer feature. Every state transition is saved, so you can resume interrupted workflows, replay for debugging, and implement human-in-the-loop by pausing at any node.


    ### Human-in-the-Loop with LangGraph
# Add an interrupt point before processing refunds
graph.add_node("request_approval", request_human_approval)

# In the approval node:
async def request_human_approval(state: SupportState) -> SupportState:
    if state["order"]["amount"] > 10000:  # > $100
        # This pauses the graph until a human responds
        raise NodeInterrupt("Refund > $100 needs approval")
    return state

# Resume after human approves:
await app.ainvoke(
    None,  # No new input, just resume
    config={"configurable": {"thread_id": "conv-123"}}
)
    ### LangGraph Trade-offs

        ProsCons
        Visual graph structureLangChain ecosystem lock-in
        Built-in checkpointingLearning curve for graph concepts
        Human-in-the-loop nativeDebugging complex graphs is hard
        Streaming supportOverhead for simple workflows
        LangGraph Studio for visualizationState management gets complex


    ## Approach 2: Temporal — Durable Workflow Orchestration

    Temporal is an enterprise workflow engine originally designed for microservices. It's overkill for simple agents but perfect for **long-running, mission-critical workflows** that must never lose state.

    ### Why Temporal for Agents?


        - **Durable execution** — If your server crashes mid-workflow, Temporal resumes exactly where it left off
        - **Built-in retries** — Configure retry policies per activity (exponential backoff, max attempts)
        - **Timeouts** — Activity timeouts, workflow timeouts, heartbeat timeouts
        - **Versioning** — Deploy new workflow versions without breaking running workflows
        - **Visibility** — Built-in UI shows every workflow, its state, and history


    ### Example: Research Report Agent
from temporalio import workflow, activity
from datetime import timedelta

@activity.defn
async def search_web(query: str) -> list[str]:
    """Search the web and return relevant URLs."""
    return await web_search_tool.search(query, top_k=10)

@activity.defn
async def scrape_page(url: str) -> str:
    """Scrape and extract content from a URL."""
    return await scraper.extract(url)

@activity.defn
async def analyze_content(content: str, question: str) -> dict:
    """Use LLM to analyze scraped content."""
    return await llm.analyze(content, question)

@activity.defn
async def write_report(findings: list[dict], topic: str) -> str:
    """Generate final research report."""
    return await llm.generate_report(findings, topic)

@workflow.defn
class ResearchWorkflow:
    @workflow.run
    async def run(self, topic: str) -> str:
        # Step 1: Search for sources (with retry)
        urls = await workflow.execute_activity(
            search_web,
            topic,
            start_to_close_timeout=timedelta(seconds=30),
            retry_policy=RetryPolicy(maximum_attempts=3)
        )

        # Step 2: Scrape pages in parallel
        scrape_tasks = [
            workflow.execute_activity(
                scrape_page,
                url,
                start_to_close_timeout=timedelta(seconds=60),
                retry_policy=RetryPolicy(maximum_attempts=2)
            )
            for url in urls[:5]  # Top 5 results
        ]
        contents = await asyncio.gather(*scrape_tasks, return_exceptions=True)
        contents = [c for c in contents if isinstance(c, str)]

        # Step 3: Analyze each source
        findings = []
        for content in contents:
            finding = await workflow.execute_activity(
                analyze_content,
                args=[content, topic],
                start_to_close_timeout=timedelta(seconds=120),
            )
            findings.append(finding)

        # Step 4: Write final report
        report = await workflow.execute_activity(
            write_report,
            args=[findings, topic],
            start_to_close_timeout=timedelta(seconds=180),
        )

        return report
    ### Temporal Trade-offs

        ProsCons
        Battle-tested durability (used by Uber, Netflix)Heavy infrastructure (Temporal server + DB)
        Survives crashes, deploys, outagesSteep learning curve
        Built-in retry, timeout, versioningOverkill for simple agents
        Great visibility UIOperational complexity
        Language-agnostic (Python, Go, Java, TS)Adds 50-100ms latency per activity


    ## Approach 3: Custom Orchestrator

    Sometimes you don't need a framework. A custom orchestrator gives you full control with minimal dependencies.
import asyncio
from dataclasses import dataclass, field
from enum import Enum

class StepStatus(Enum):
    PENDING = "pending"
    RUNNING = "running"
    COMPLETED = "completed"
    FAILED = "failed"
    SKIPPED = "skipped"

@dataclass
class Step:
    name: str
    fn: callable
    depends_on: list[str] = field(default_factory=list)
    retry_count: int = 2
    timeout_seconds: int = 60
    condition: callable = None  # Skip if returns False
    status: StepStatus = StepStatus.PENDING
    result: any = None
    error: str = None

class Orchestrator:
    def __init__(self):
        self.steps: dict[str, Step] = {}
        self.context: dict = {}

    def add_step(self, step: Step):
        self.steps[step.name] = step

    async def run(self) -> dict:
        while self._has_pending_steps():
            # Find steps ready to run (dependencies met)
            ready = [s for s in self.steps.values()
                    if s.status == StepStatus.PENDING
                    and self._dependencies_met(s)]

            if not ready:
                break  # Deadlock or all done

            # Run ready steps in parallel
            tasks = [self._execute_step(s) for s in ready]
            await asyncio.gather(*tasks)

        return self.context

    async def _execute_step(self, step: Step):
        # Check condition
        if step.condition and not step.condition(self.context):
            step.status = StepStatus.SKIPPED
            return

        step.status = StepStatus.RUNNING

        for attempt in range(step.retry_count + 1):
            try:
                result = await asyncio.wait_for(
                    step.fn(self.context),
                    timeout=step.timeout_seconds
                )
                step.result = result
                step.status = StepStatus.COMPLETED
                self.context[step.name] = result
                return
            except asyncio.TimeoutError:
                step.error = f"Timeout after {step.timeout_seconds}s"
            except Exception as e:
                step.error = str(e)

            if attempt  bool:
        return all(
            self.steps[dep].status == StepStatus.COMPLETED
            for dep in step.depends_on
        )

    def _has_pending_steps(self) -> bool:
        return any(s.status == StepStatus.PENDING for s in self.steps.values())

# Usage
orch = Orchestrator()
orch.add_step(Step("classify", classify_intent))
orch.add_step(Step("authenticate", authenticate, depends_on=["classify"],
                    condition=lambda ctx: ctx["classify"]["requires_auth"]))
orch.add_step(Step("lookup", lookup_order, depends_on=["authenticate"]))
orch.add_step(Step("respond", generate_response, depends_on=["lookup"]))

result = await orch.run()
    ### Custom Orchestrator Trade-offs

        ProsCons
        Full control, zero dependenciesYou build everything yourself
        Minimal overheadNo built-in persistence/recovery
        Easy to understand and debugParallel execution logic is tricky
        Fits any patternGrows complex over time


    ## Orchestration Patterns

    ### Pattern 1: Sequential Pipeline
    Steps run one after another. Output of step N is input to step N+1.

    **Use when:** Each step depends on the previous result. Example: classify → retrieve → generate → validate.

    ### Pattern 2: Fan-Out / Fan-In
    One step spawns multiple parallel tasks, then a final step aggregates results.

    **Use when:** Independent subtasks can run simultaneously. Example: search 5 sources in parallel → merge findings.
# Fan-out / Fan-in with LangGraph
from langgraph.graph import StateGraph

async def fan_out(state):
    """Create parallel research tasks."""
    tasks = [{"query": q} for q in state["queries"]]
    return {"parallel_tasks": tasks}

async def research(state):
    """Run research for one query."""
    return {"finding": await search_and_analyze(state["query"])}

async def fan_in(state):
    """Merge all findings into a report."""
    return {"report": await synthesize(state["findings"])}
    ### Pattern 3: Conditional Branching
    Different paths based on runtime conditions.

    **Use when:** The workflow varies by input type. Example: refund requests go through approval, general questions go straight to response.

    ### Pattern 4: Loop with Exit Condition
    Repeat a sequence until a condition is met.

    **Use when:** Iterative refinement is needed. Example: generate → evaluate → if score < threshold, regenerate.
# Loop pattern in LangGraph
def should_continue(state) -> Literal["regenerate", "finalize"]:
    if state["quality_score"] >= 0.8:
        return "finalize"
    if state["attempts"] >= 3:
        return "finalize"  # Give up after 3 tries
    return "regenerate"

graph.add_conditional_edges("evaluate", should_continue)
    ### Pattern 5: Saga (Compensating Transactions)
    When a later step fails, undo earlier steps.

    **Use when:** Multi-step operations that should be atomic. Example: reserve inventory → charge card → if shipping fails → refund card → release inventory.

    ## Choosing Your Orchestration Approach


        ScenarioBest ApproachWhy
        Simple agent (3-5 steps)Custom or just ReActFrameworks add unnecessary complexity
        Complex but short-lived (< 5 min)LangGraphGood graph model, checkpointing, HITL
        Long-running (hours/days)TemporalDurable execution survives crashes
        Human approval workflowsLangGraph or TemporalBoth have native interrupt/signal support
        Mission-critical / financialTemporalBattle-tested, audit trail, exactly-once
        Maximum flexibilityCustomNo framework constraints
        Team already uses LangChainLangGraphEcosystem integration


    ## Common Orchestration Mistakes

    ### 1. Over-Orchestrating Simple Agents
    If your agent has 3 steps and no branching, you don't need LangGraph or Temporal. A simple while loop with tool calling is fine. Add orchestration when complexity justifies it.

    ### 2. No Error Boundaries
    A failure in step 4 shouldn't crash the entire workflow. Each step needs its own error handling: retry policy, fallback behavior, and graceful degradation.

    ### 3. Missing Timeouts
    LLM calls can hang. Tool calls can hang. Without timeouts, your workflow hangs forever. Set timeouts on every async operation: 30s for LLM calls, 60s for tool calls, 5 minutes for the full workflow.

    ### 4. No Observability
    If you can't see which step is running, which failed, and why, you can't debug production issues. Log every step transition with timing, input/output, and status.

    ### 5. Tight Coupling Between Steps
    Steps should communicate through state, not direct calls. This makes them independently testable, replaceable, and reorderable.


        Designing agent orchestration? [AI Agents Weekly](/newsletter.html) covers workflows, frameworks, and production deployment patterns 3x/week. Join free.



    ## Conclusion

    Orchestration is the boring infrastructure that makes agents reliable. It's the difference between a demo that works 80% of the time and a production system that handles edge cases, recovers from failures, and scales.

    Start simple: if your agent has fewer than 5 steps, a custom orchestrator or plain ReAct loop is enough. Move to LangGraph when you need conditional branching, human-in-the-loop, or checkpointing. Graduate to Temporal when workflows run for hours, involve financial transactions, or must survive infrastructure failures.

    The best orchestration is the one you don't notice — it just makes your agent work, every time.

Get our free AI Agent Starter Kit — templates, checklists, and deployment guides for building production AI agents.