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.

Design Tokens

Design tokens are a single source of truth for reusable design properties, stored in such a way that computers and humans can easily read and process them. They represent core design decisions and translate them into a structured, sharable, and reusable format.

Examples of design tokens include:

Colors: primary-color: #FF5733
Typography: font-size-large: 16px
Spacing: spacing-sm: 8px
Borders: border-radius-md: 4px

Why Do Design Tokens Matter?

Historically, developers and designers often worked with separate tools and formats, leading to issues like:

  • Inconsistencies in design implementation.
  • Duplication of effort when updating or scaling designs.
  • Miscommunication between teams.
  • By using design tokens, these issues are minimized because tokens act as a shared, universal language.

How Design Tokens Work

Core Principles

  1. Platform-agnostic: Stored in formats like JSON or YAML so they can be consumed by any system—web, mobile, or desktop.
  2. Scalable: Tokens allow changes to propagate easily across all designs and implementations.
  3. Customizable: Tokens can adapt to themes or user preferences, like dark mode.

Structure of Tokens for developers at work
Tokens are often stored hierarchically, e.g.:

{
  "color": {
    "primary": {
      "default": "#FF5733",
      "hover": "#FF7845",
      "active": "#FF4620"
    }
  },
  "typography": {
    "font-size": {
      "small": "12px",
      "medium": "16px",
      "large": "20px"
    }
  }
}

How They Benefit Designers

Consistency Across Designs:
A centralized repository ensures that every design adheres to brand standards.
For example, if the brand’s primary color changes, updating the token reflects this change everywhere.

Efficient Prototyping:
Tokens allow designers to create prototypes that directly match the end product.

Bridge to Developers:
Design tokens provide a common language between design tools (like Figma or Adobe XD) and development tools.

Supports Dynamic Design Systems:
For responsive or adaptive designs, tokens enable scalability (e.g., changing spacing or font sizes across devices).

** ## How They Benefit Developers**

Reduced Manual Work:
Developers can pull tokens from a shared library instead of hardcoding values.

Improved Scalability:
When the design changes (e.g., a new color scheme for a theme), tokens enable quick updates without hunting down all instances.

Cross-Platform Consistency:
A single set of tokens can be applied across web, iOS, and Android platforms.

Facilitates Theming:
Tokens can be used to define multiple themes (e.g., light mode, dark mode, brand variations).

How Design Tokens Fit into Design Systems

Design tokens are a foundational element of design systems, which are comprehensive collections of reusable components, patterns, and guidelines.

Tokens provide:

Atomic definitions (e.g., colors, fonts, spacing) that feed into:

  • Components (buttons, cards, etc.)
  • Patterns (layouts, grids)

For example:
If a button uses primary-color for its background, updating the token instantly changes all buttons across the system.

Tools for Managing Design Tokens

Design Tools:
Figma Tokens Plugin: Automates token management within Figma.
Adobe XD: Allows exporting assets as tokens.

Token Management Platforms:
Style Dictionary: A tool by Amazon for transforming and managing tokens for multiple platforms.
Token Studio: Focuses on synchronizing design tokens between tools.

Development Frameworks:
Tailwind CSS: Utilizes a token-like approach with utility-first CSS.
Theme UI: Integrates tokens for React-based theming.

Practical Example: Button Styling with Tokens
Imagine styling a button component using tokens:

Design Token Definition (JSON):

{
  "color": {
    "button": {
      "background": "#3498db",
      "text": "#ffffff"
    }
  },
  "spacing": {
    "padding": "12px 24px"
  },
  "border": {
    "radius": "8px"
  }
}

CSS Using Tokens:

.button {
  background-color: var(--color-button-background);
  color: var(--color-button-text);
  padding: var(--spacing-padding);
  border-radius: var(--border-radius);
}

React Component:

const Button = () => (
  <button className="button">Click Me</button>
);

Result: Changing the color.button.background token in JSON changes the button’s background color everywhere.

How Design Tokens Make Life Easier

For Designers:

  • Streamlined workflows.
  • Fewer handoffs and iterations with developers.
  • Easier brand updates.

For Developers:

  • Faster implementation and reduced bugs.
  • Unified experience across platforms.
  • Simplified maintenance and scaling.
  • In short, design tokens bridge the gap between design and development, improving collaboration and ensuring consistency. They’re indispensable for modern, scalable design systems.

Week 2 – Learning K8s

Week 2 Recap

I decided to use Claude for some help. Not to login and create the setup for me (although I will do that later). More to build me a guide so I can learn for myself. I had Claude create steps for me to complete a cluster configuration. Pretty straight forward.

After some iterations I was able to get a working flow. So now as I need more control planes or workers I can easily expand my cluster OR configure/setup a new cluster. Later on if I need a break from K8 I’ll take a look at using Ansible to help me make this a more standard process.

But at the end of this week I had a 6 total vms deployed. Three were for a control plane, Three were worker nodes. I haven’t quite made it through testing if I actually have an HA setup.

What’s next?

Next week I want to test some of the HA functionality. Turn off some control planes, turn off some workers, etc. and see what happens. After that maybe deploy an App? I’m going to start with something stateless by design to simplify the components.

See you next week!