Quilter Project Speedrun: AI-Designed Computer Boots Successfully — Autonomous PCB Layout Validated

Quilter has published the complete results of their “Project Speedrun” — an entire computer designed from schematic to fabricated board using their AI layout engine, culminating in successful power-on and real-workload validation.

Why This Matters for Developers

If you work with hardware — whether designing IoT devices, building custom development boards, or working on embedded systems — this represents a fundamental shift in how PCBs get designed.

Previous AI PCB layout demonstrations showed DRC-clean output. Project Speedrun demonstrates the full pipeline to functional hardware:

  • The board powers on without issues
  • It boots an operating system
  • It runs sustained workloads without signal integrity failures
  • No respin was required — first-spin success

This transitions from “AI can route traces” to “AI can design hardware that works.”

How Quilter’s Autonomous Layout Works

The workflow:

  1. Input: Standard schematic netlist with component specifications
  2. Constraint extraction: AI automatically identifies interface types (DDR, USB, power delivery), assigns impedance targets, and determines placement priorities
  3. Autonomous layout: Placement and routing performed entirely by AI, producing DRC-clean output
  4. Human cleanup: A brief precision pass for mechanical details (mounting holes, connector orientation, silkscreen)
  5. Fabrication and assembly: Standard PCB manufacturing and SMT assembly
  6. Validation: Electrical testing, power-on, and functional verification

Circuit Comprehension — Not Just Auto-Routing

Quilter’s system doesn’t just connect pins — it understands circuits:

  • Identifies power delivery networks and applies appropriate plane strategy
  • Recognizes high-speed differential pairs and applies impedance-matched routing
  • Understands decoupling cap placement relative to IC power pins
  • Distinguishes analog sensitivity from digital noise tolerance

The Competitive Landscape in 2026

Three approaches to AI PCB layout are emerging:

Tool Approach Best For
Quilter Full autonomy (netlist to layout) Speed-focused teams, standard digital boards
Siemens Fuse AI agent orchestration Enterprise, complex systems
Flux.ai Cloud-native collaborative AI Small teams, rapid prototyping

Current Limitations

  • High-speed serial: 112G SerDes routing with tuning requirements pushes current boundaries
  • Mixed-signal: Analog/digital partition decisions require system-level understanding
  • RF/microwave: Impedance matching and transmission line design remain challenging
  • Factory-specific rules: Different manufacturers have different capabilities

The 80/20 Rule

Most engineers report AI handles approximately 80% of routing work on standard designs. The remaining 20% requires human expertise. But the time savings are enormous: 3-week layout compressed to 2-3 days.

What This Means for the Hardware Developer Community

The barrier to custom hardware has dropped significantly. Teams that needed $50-100K in layout engineering can potentially go schematic-to-fabrication in days. Within 3-5 years, 60-80% of new PCB layouts will likely involve AI significantly.

Originally published at AtlasPCB. Source: Quilter Blog.

Whether your PCB is AI-designed or hand-crafted, it needs expert fabrication. AtlasPCB provides controlled impedance, 100% electrical testing, and free DFM review. Upload your design

KV Cache Explained Like You’re an LLM Engineer

Decision Matrix

How transformer inference actually works under the hood — and why KV cache is the single most important optimization keeping your LLM from crawling.

If you’ve ever wondered why LLMs respond fast even on long prompts — the answer is KV cache. But most explanations stop at “it stores keys and values.” This goes deeper.

What You’ll Learn
By the end of this article you’ll understand:

  • Why autoregressive LLM generation is expensive by design
  • What attention actually computes — and why recomputing it is wasteful
  • The difference between prefill and decode phases
  • How KV cache grows and when it becomes your GPU’s worst enemy
  • How vLLM’s PagedAttention solved memory fragmentation
  • What’s coming next: quantized cache, sliding window, speculative decoding

Introduction: Why LLM Inference is Expensive
Let’s start with an uncomfortable truth.
When you send a prompt to GPT-4 or Claude and watch that first token appear, your GPU has just burned through millions of floating-point operations before producing a single character. And then, for every subsequent token in the response — it does it again.
Not a small version. The full computation. Attention over the entire sequence. Every time.

Without optimization, a 7B parameter model generating a 200-token response would recompute attention across the full growing sequence 200 times. For a 70B model on a context of 4,096 tokens, that’s not slow — it’s practically unusable in production.
This is the core economics problem of LLM inference: autoregressive generation is inherently sequential and expensive. You can’t parallelize generation across output tokens the way you parallelize training across a batch. Each new token depends on every token that came before it.

KV cache is the engineering solution that makes modern LLM inference economically viable. It’s not magic — it’s a deliberate memory-compute tradeoff. Understanding it deeply is the difference between an ML engineer who deploys models and one who optimizes them.

What Happens During Token Generation

Before we talk about caching, let’s understand what we’re caching from.
LLMs generate text one token at a time, left to right. Each generation step:

Takes the full input prompt + all previously generated tokens
Runs a complete forward pass through the transformer
Produces a probability distribution over the vocabulary
Samples the next token from that distribution

Then the new token is appended to the sequence, and the process repeats.
Here’s that loop in pseudocode:

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf")
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")

input_ids = tokenizer.encode("The capital of France is", return_tensors="pt")

generated = input_ids
for _ in range(50):  # generate 50 tokens
    with torch.no_grad():
        # Full forward pass every single step — expensive!
        outputs = model(generated)
        logits = outputs.logits[:, -1, :]        # last token's logits
        next_token = torch.argmax(logits, dim=-1)
        generated = torch.cat([generated, next_token.unsqueeze(0)], dim=-1)

print(tokenizer.decode(generated[0]))

Notice the problem: at step 50, generated has 50+ tokens. We’re doing a full transformer forward pass over all of them just to predict token 51. The computation keeps growing linearly with sequence length.

The question is: why do we need to reprocess all previous tokens every time?

Transformer Attention: A Quick Refresher
To understand why KV cache exists, you need to understand what attention actually computes.
The core of transformer inference is multi-head self-attention. For each layer and each token position, attention computes three projections:

Q (Query): “What am I looking for?”
K (Key): “What do I contain?”
V (Value): “What do I actually carry?”

The attention output for position i is:

Attention(Q_i, K, V) = softmax(Q_i · K^T / √d_k) · V

In words: token i asks a question (Q), broadcasts it across all token keys (K) to get attention weights, then uses those weights to take a weighted sum of all values (V).
For a sequence of n tokens, each token attends to all n tokens. This is O(n²) in both time and memory — which is why long contexts hurt so much.

Here’s the key insight:

Q changes at every step. But K and V for already-processed tokens do NOT change.
Once a token has been processed by the transformer, its Key and Value projections are fixed. They only depend on the token’s content and position — not on future tokens.

This is the mathematical justification for KV cache.

Why Recomputing Attention is Inefficient
Let’s make this concrete with numbers.
A standard LLaMA-2 7B model has:

32 transformer layers
32 attention heads
Hidden dimension of 4096
KV head dimension of 128

For a single token, the K and V projections at one layer are each vectors of size 128. Across 32 heads and 32 layers, storing the KV state for one token costs:

2 (K and V) × 32 (layers) × 32 (heads) × 128 (head_dim) × 2 bytes (fp16)
= 524,288 bytes ≈ 0.5 MB per token

Now imagine a prompt of 2,048 tokens:

2,048 tokens × 0.5 MB = 1 GB of KV state

Without caching, every decode step recomputes that entire 1 GB of KV state from scratch. With 200 decode steps, you’re recomputing 200 GB of equivalent computation — just to generate a few hundred words.
The recompute path also saturates memory bandwidth. On an A100 (2 TB/s bandwidth), even just reading model weights once per step for a 13B model takes:

13B params × 2 bytes/param = 26 GB per forward pass
26 GB / 2000 GB/s = ~13ms per token → ~77 tokens/sec ceiling

Any redundant recomputation cuts directly into this budget.

What KV Cache Stores
The KV cache stores the Key and Value tensors for all previously processed tokens, so they don’t need to be recomputed on subsequent decode steps.
Here’s the conceptual layout:

KV Cache structure diagram showing Key and Value arrays stored per attention head across all transformer layers

At each decode step, the model:

  • Computes Q only for the new token
  • Computes K and V for the new token
  • Appends new K/V to the cache
  • Runs attention using new Q against all cached K/V
  • Returns the output logit for the new token

Attention with KV cache:

def attention_with_cache(query_new, key_cache, value_cache, key_new, value_new):
    # Append new K/V to cache
    keys   = torch.cat([key_cache,   key_new],   dim=1)
    values = torch.cat([value_cache, value_new], dim=1)

    # New query attends to ALL keys (cached + new)
    scores  = torch.einsum("bhd,bshd->bhs", query_new, keys) / math.sqrt(d_k)
    weights = torch.softmax(scores, dim=-1)
    output  = torch.einsum("bhs,bshd->bhd", weights, values)

    return output, keys, values  # return updated cache

Result: instead of O(n) recompute per step, we do O(1) query computation — a constant cost regardless of how long the sequence has grown.

Prefill Phase vs. Decode Phase
Production LLM inference has two fundamentally different operating modes.
Prefill Phase

When you first submit a prompt, the model processes all prompt tokens in parallel:

Prompt: [T_0, T_1, T_2, ..., T_2047]
          ↓    ↓    ↓         ↓
    [All processed simultaneously via batch matrix ops]
          ↓
    [Full KV cache populated for all 2048 positions]
          ↓
    [First output token generated]

Prefill is compute-bound — large matrix multiplications across all positions at once. GPUs excel here.

Prefill time = your Time to First Token (TTFT). Users feel this as the delay before the first character appears.
Decode Phase

After prefill, each new token is generated one at a time:

Step 1: New token T_2048
        → Compute Q, K, V for T_2048 only
        → Append K_2048, V_2048 to cache
        → Attend over positions 0..2048
        → Sample T_2049

Step 2: New token T_2049
        → Cache now has 2050 entries
        → Attend over positions 0..2049
        → Sample T_2050

Decode is memory-bandwidth-bound — constantly reading the growing KV cache from GPU HBM. Compute cores sit largely idle waiting for memory reads.

Decode step memory reads (LLaMA 7B, 2048 cache):
  Model weights:  ~14 GB
  KV cache:       ~1 GB (and growing)
  Total:          ~15 GB

At 2 TB/s bandwidth: ~7.5ms/token → ~133 tokens/sec theoretical max

GPU Memory and KV Cache Growth
Here’s where things get expensive fast.

KV cache size =
  batch_size × seq_len × num_layers × num_heads × head_dim × 2 × dtype_bytes

Real example — LLaMA-2 13B, batch size 8, 4K context:

batch_size   = 8
seq_len      = 4096
num_layers   = 40
num_kv_heads = 40
head_dim     = 128
dtype_bytes  = 2      # fp16

kv_cache_bytes = (
    batch_size * seq_len * num_layers
    * num_kv_heads * head_dim * 2 * dtype_bytes
)
print(f"KV Cache: {kv_cache_bytes / 1e9:.2f} GB")
# KV Cache: 26.84 GB

Now scale to batch 16 at 8K context:

batch_size = 16
seq_len    = 8192
# KV Cache: 107.37 GB  ← won't fit on a single A100

A100-80GB Memory Budget (LLaMA-2 13B, fp16)

┌─────────────────────────────────────────────┐
│  Model Weights          ~26 GB              │
├─────────────────────────────────────────────┤
│  KV Cache               ~30–40 GB           │  ← the battleground
├─────────────────────────────────────────────┤
│  Activations / Other    ~5–10 GB            │
├─────────────────────────────────────────────┤
│  CUDA Runtime / Misc    ~2–3 GB             │
└─────────────────────────────────────────────┘

The KV cache is the only dynamic component. It grows as sequences get longer, shrinks as sequences end, and fragments if not managed carefully. Every other component is fixed at load time.

Continuous Batching and PagedAttention
The Problem with Static Batching
Traditional inference servers used static batching: wait for N requests, run until all finish. But requests finish at different times. 7 out of 8 requests finishing at step 50 while one runs to step 500 means your GPU is 87.5% idle on the long tail.
Continuous Batching

Modern engines batch at the iteration level, not the request level:

Time →  T0  T1  T2  T3  T4  T5  T6  T7
Slot 0: [A   A   A   A   ←done→  E   E   E ]
Slot 1: [B   B   ←done→  D   D   D   D     ]
Slot 2: [C   C   C   C   C   C   ←done→  F ]

As soon as request A finishes, slot 0 immediately starts request E. GPU utilization stays high.

But this creates a new problem: KV cache memory fragmentation. Different requests have different lengths. You can’t pre-allocate contiguous memory blocks without wasting huge amounts.

PagedAttention (vLLM)

vLLM’s PagedAttention (2023) solved this with a virtual memory analogy borrowed from OS paging.

Instead of contiguous KV blocks per request, memory is split into fixed-size pages (typically 16 tokens each). A block table maps logical pages to physical GPU memory:

Logical view (Sequence A):
  [Page 0 → Page 1 → Page 2 → Page 3]

Physical GPU memory:
  Block 7:  tokens  0–15  of Sequence A
  Block 2:  tokens 16–31  of Sequence A
  Block 15: tokens 32–47  of Sequence A
  Block 3:  tokens 48–63  of Sequence A

Block table:
  Seq A: { logical 0 → physical 7,
           logical 1 → physical 2,
           logical 2 → physical 15,
           logical 3 → physical 3 }

Benefits:

  • No internal fragmentation — allocate one page at a time as
    sequence grows

    • Prefix sharing — identical system prompt KV pages shared across
      thousands of requests (copy-on-write)
    • Swapping — pages can be evicted to CPU memory under pressure

Memory waste drops from ~30–40% (fragmentation) to under 4%.

# Simplified block table concept
class BlockTable:
    def __init__(self, block_size=16, num_blocks=1000):
        self.block_size  = block_size
        self.free_blocks = list(range(num_blocks))
        self.block_data  = {}   # physical_block_id → KV tensor
        self.tables      = {}   # seq_id → {logical → physical}

    def allocate_block(self):
        return self.free_blocks.pop()

    def get_kv(self, seq_id, logical_block_idx):
        physical = self.tables[seq_id][logical_block_idx]
        return self.block_data[physical]

How vLLM and Inference Engines Optimize KV Cache

vLLM in Practice

from vllm import LLM, SamplingParams

llm = LLM(
    model="meta-llama/Llama-2-7b-hf",
    tensor_parallel_size=2,        # split across 2 GPUs
    gpu_memory_utilization=0.90,   # leave 10% headroom
    max_model_len=4096
)

outputs = llm.generate(
    ["Explain KV cache to an ML engineer"],
    SamplingParams(temperature=0.7, max_tokens=512)
)

vLLM ships: PagedAttention, continuous batching, Flash Attention v2, tensor parallelism, and prefix caching out of the box.

Flash Attention
Flash Attention avoids the expensive O(n²) memory allocation by tiling computations to fit in SRAM:

Standard attention:
  Q, K, V → HBM → compute N×N matrix → HBM → output
  Memory: O(n²)

Flash Attention:
  Q, K, V → SRAM tiles → fused kernel → output
  Never materializes full N×N matrix in HBM
  Memory: O(n)   Speed: 2–4× faster

Prefix Caching

If thousands of users share the same system prompt, their KV caches for that prefix are identical. Compute it once, share it everywhere:

Without prefix caching:
  Request 1: compute KV for [SYSTEM: 2048 tokens] + [query A]
  Request 2: compute KV for [SYSTEM: 2048 tokens] + [query B]
  Request 3: compute KV for [SYSTEM: 2048 tokens] + [query C]

With prefix caching:
  Request 1: compute + store KV for [SYSTEM]
  Requests 2, 3, ...: load cached KV, compute only [query N]

TTFT reduction: 50–90% on system-prompt-heavy workloads.

KV Cache Challenges in Long-Context Models

Modern models like GPT-4o, Claude 3.5, and Gemini 1.5 Pro support 128K–1M token contexts. This creates KV cache problems at a completely different scale.

Memory at 128K Context

# LLaMA-3 70B with GQA (8 KV heads), 128K context, batch size 1
seq_len      = 131072   # 128K tokens
num_layers   = 80
num_kv_heads = 8        # Grouped Query Attention
head_dim     = 128
dtype_bytes  = 2

kv_cache_bytes = seq_len * num_layers * num_kv_heads * head_dim * 2 * dtype_bytes
print(f"KV Cache: {kv_cache_bytes / 1e9:.2f} GB")
# KV Cache: 21.47 GB  ← for ONE sequence

4 concurrent requests = 4 × 80 GB H100s just for KV cache.

Decode Bandwidth at 128K

KV cache read per decode step:  21 GB
H100 HBM bandwidth:             3.35 TB/s
Time reading KV cache:          21 / 3350 ≈ 6.3ms per token
→ Maximum ~158 tokens/sec, memory-bandwidth limited

That ceiling gets worse with every new token added.

What Long-Context Models Use

Table comparing long-context attention techniques: Flash Attention avoids O(n²) memory at compute cost, GQA/MQA reduces KV heads with slight quality loss, Sliding Window Attention limits context range, and RoPE requires training but scales position encoding

Real-World Production Problems

OOM Crashes Under Load

Without PagedAttention, a single unexpectedly long sequence in a batch can OOM the entire batch. Modern engines handle this with eviction policies and graceful degradation — but this requires careful tuning of gpu_memory_utilization and per-request max_tokens limits.

Prefill-Decode Disaggregation
At scale, teams separate prefill and decode onto different hardware:

  • Prefill servers — compute-heavy, large batches, throughput-
    optimized
  • Decode servers — bandwidth-heavy, latency-sensitive, TTFT-optimized

The KV cache is computed on a prefill server then transferred to a decode server over InfiniBand or NVLink. This is the architecture behind services like Anyscale Endpoints and AWS Inferentia2 deployments.

Batching Heterogeneous Request Lengths

Batching a 100-token request with a 4096-token request pads the short one to max length — wasting KV cache memory and compute. Solutions:

  • Bucketing — group requests by length ± Δ
  • Dynamic padding — pad to nearest power of 2
  • Chunked prefill — break long prefills into fixed-length chunks

Cache Eviction Under Memory Pressure

When GPU memory is exhausted, KV pages must be evicted. Common policies:

  • LRU — evict sequences not accessed recently
  • Priority-based — keep high-SLA requests in GPU, swap background
    jobs to CPU

    • Recompute on miss — evict aggressively, recompute KV from prompt
      if needed

Future Optimizations

KV Cache Quantization

Standard KV:    FP16 → 2 bytes/element
INT8 KV:        INT8 → 1 byte/element   (2× memory reduction)
INT4 KV:        INT4 → 0.5 bytes/element (4× memory reduction)

Research (KVQuant, KIVI) shows INT8 quantization has minimal perplexity impact. INT4 is viable for values (keys are more sensitive to quantization).

# INT8 KV cache via per-channel quantization
def quantize_kv(tensor, dtype=torch.int8):
    scale      = tensor.abs().max(dim=-1, keepdim=True).values / 127
    quantized  = (tensor / scale).to(dtype)
    return quantized, scale

def dequantize_kv(quantized, scale):
    return quantized.float() * scale

Sliding Window Attention

Standard:       token_i attends to tokens [0, i]      → O(seq_len) KV cache
Sliding window: token_i attends to tokens [i-W, i]    → O(W) KV cache

Mistral 7B uses W=4096 with a rolling buffer — KV cache memory stays constant regardless of sequence length.

Speculative Decoding

Without speculation:
  Step 1 → token 1
  Step 2 → token 2
  ...5 steps → 5 tokens

With speculation:
  Draft model generates 5 candidate tokens (1 fast pass)
  Target model verifies all 5 in 1 parallel pass
  Accept ~4–5 tokens in 2 steps instead of 5
  → 2–3× throughput improvement

Implementations: vLLM speculative decoding, Medusa, EAGLE.

Multi-head Latent Attention (MLA)
DeepSeek-V2’s MLA caches a low-rank compressed representation instead of full K/V tensors, then decompresses on-the-fly during decode:

Standard KV:  Store K [d_k] + V [d_v] per token per layer
MLA:          Store c [d_c] (compressed), decompress when needed
              d_c << d_k + d_v → up to 5× smaller KV footprint

Final Summary

┌─────────────────────────────────────────────────────────────┐
│                    LLM INFERENCE PIPELINE                    │
│                                                             │
│  INPUT PROMPT                                               │
│       ↓                                                     │
│  ┌─────────────┐                                            │
│  │   PREFILL   │  All prompt tokens processed in parallel   │
│  │   PHASE     │  Compute-bound → sets your TTFT            │
│  └──────┬──────┘  Full KV cache populated                   │
│         ↓                                                   │
│  ┌─────────────┐                                            │
│  │   DECODE    │  One token per step, autoregressive        │
│  │   PHASE     │  Memory-bandwidth-bound                    │
│  │  [NEW TOK]  │  Q for new token only                      │
│  │             │  K/V appended to cache                     │
│  │             │  Attend over all cached K/V                │
│  └──────┬──────┘  → Sample next token                       │
│         └──→ Repeat until EOS or max_tokens                 │
└─────────────────────────────────────────────────────────────┘

What KV cache does:
Stores computed Key and Value tensors for all processed tokens, eliminating recomputation at each decode step. Per-step attention drops from O(n) recompute to O(1) compute + cache read.

Why it’s a bottleneck:
Grows linearly with sequence length. At 128K+ contexts, consumes tens of GBs per request. Decode becomes memory-bandwidth-limited.

How modern engines solve it:

Table showing inference engine KV cache optimizations: PagedAttention in vLLM eliminates fragmentation, Continuous batching in vLLM/TGI/SGLang maximizes GPU utilization, Flash Attention across all engines delivers O(n) memory and 2-4x speedup, and Prefix caching in vLLM/SGLang cuts TTFT by 50-90%

Where it’s going:

Table of future KV cache optimizations: INT8 quantization saves 2x memory and is production-ready, INT4 saves 4x and is in early production, sliding window attention achieves constant O(W) memory deployed in Mistral, speculative decoding gives 2-3x throughput with no memory cost, and MLA from DeepSeek-V2 achieves 5x memory reduction

LLM inference is ultimately a resource allocation problem. The GPU has a fixed memory budget, a fixed bandwidth, and a fixed compute budget. KV cache sits at the intersection of all three.

The engineers pushing the frontier on inference performance aren’t just running models faster — they’re rethinking what needs to be stored, what can be shared, what can be compressed, and what can be recomputed.

If you’re building production LLM systems, KV cache isn’t a detail. It’s the design constraint everything else bends around.

Day 9 — AWS S3

s3 dashboard

Modern cloud applications store massive amounts of data.

From:

  • website images
  • backups
  • logs
  • videos
  • static frontend files
  • financial reports

everything needs scalable storage.

That’s where Amazon S3 comes in.

And honestly?

If you’re learning Cloud, DevOps, or Security Engineering, then understanding S3 buckets and bucket policies is absolutely mandatory.

Because most real-world cloud breaches happen due to:

  • publicly exposed buckets
  • weak bucket permissions
  • misconfigured policies
  • accidentally leaked sensitive files

In this blog, we’ll deeply understand:

  • What S3 buckets are
  • Bucket policies
  • Static website hosting
  • Public access risks
  • Real-world security mistakes
  • Best practices used in enterprises

🔗 Resources

🪣 What is an S3 Bucket?

s3 dashboard

An S3 bucket is basically a container used to store files in AWS.

Think of it like:

Google Drive Folder → But infinitely scalable

Inside buckets, you can store:

  • Images
  • Videos
  • Backups
  • Application logs
  • PDFs
  • Website files
  • Terraform states
  • Kubernetes backups

AWS S3 is designed for:

  • high durability
  • massive scalability
  • global availability

AWS claims:

S3 provides 99.999999999% durability.

That’s 11 nines.

🏗️ S3 Architecture

s3 architecture

📦 Creating Your First Bucket

When creating a bucket, AWS asks for:

  • Bucket name
  • Region
  • Object ownership
  • Public access settings
  • Versioning
  • Encryption

Example bucket names:

s3 bucket

⚠️ Bucket names must be globally unique.

🌍 S3 Bucket Use Cases

Common Enterprise Use Cases

Use Case Example
Static website hosting React frontend
Backup storage DB backups
Log storage CloudTrail logs
Data lake Analytics
Disaster recovery Encrypted snapshots
Media storage Images/videos

🔐 Understanding Bucket Policies

s3 images

User Request → Policy Evaluation → Allow / Deny

Bucket policies control:

Who can access your bucket and what they can do.

These are JSON-based IAM policies attached directly to the bucket.

Example permissions:

  • Read objects
  • Upload files
  • Delete files
  • Public access
  • Restrict access by IP
  • Enforce HTTPS

🧠 Example Bucket Policy

s3 bucket policy

This policy means:

Anyone on the internet can read files from this bucket.

Perfect for:

  • public websites
  • images
  • CSS/JS files

Dangerous for:

  • financial documents
  • backups
  • secrets

🚨 Public Access Risks (Most Important Section)

This is where most beginners make mistakes.

Thousands of companies have leaked sensitive data because:

  • S3 buckets became public
  • developers disabled block public access
  • bucket policies were misconfigured

Real leaked data examples:

  • customer records
  • bank documents
  • API keys
  • employee data
  • application backups

⚠️ Common S3 Misconfigurations

1️⃣ Public Buckets

Everyone on the internet can access files.

2️⃣ No Encryption

Sensitive files stored without:

  • SSE-S3
  • SSE-KMS

3️⃣ Weak IAM Permissions

Example:

s3:*

for all users.

Very dangerous.

4️⃣ No Logging Enabled

Without logging:

  • no audit trail
  • difficult incident response

5️⃣ Exposed Terraform State Files

Many DevOps teams accidentally expose:

terraform.tfstate

which may contain:

  • secrets
  • passwords
  • infrastructure details

🌐 Static Website Hosting Using S3

One of the coolest features of S3:

You can host static websites directly from a bucket.

Perfect for:

  • portfolio websites
  • React apps
  • documentation
  • landing pages

⚙️ How Static Website Hosting Works

s3 website hosting flow

🛠️ Steps to Enable Static Website Hosting

Step 1 — Create Bucket

Example:

30-days-cloud-devsecops-journey-day9-s3

Step 2 — Upload Files

Upload:

  • index.html
  • error.html

Step 3 — Enable Static Website Hosting

Go to:

Properties → Static Website Hosting

Enable it.

Specify:

Index document → index.html

Step 4 — Add Bucket Policy

Allow public read access.

Step 5 — Access Website URL

AWS generates:

http://30-days-cloud-devsecops-journey-day9-s3.s3-website-region.amazonaws.com

🎨 Website Hosting Screenshots

1. Uploading Files For Website hosting

Website hosting

2. Url Of Static Website

Website second hosting

3. Output of Hosted Website

s3 static hosting

🔒 Best Practices for Secure Buckets

✅ Enable Block Public Access

Unless explicitly needed.

✅ Use Least Privilege IAM

Never use:

s3:*

for everyone.

✅ Enable Encryption

Recommended:

  • SSE-S3
  • SSE-KMS

✅ Enable Versioning

Helps recover deleted files.

✅ Enable Logging

Use:

  • CloudTrail
  • S3 Access Logs

✅ Use Lifecycle Policies

Automatically:

  • archive old files
  • delete unnecessary objects

🏦 Fintech Security Perspective

In fintech environments, S3 often stores:

  • transaction logs
  • KYC documents
  • audit reports
  • backups
  • customer statements

That means:

a single public bucket can become a massive compliance violation.

This can break:

  • PCI-DSS
  • SOC2
  • ISO 27001
  • RBI compliance

That’s why enterprises:

  • heavily audit S3 permissions
  • use automated compliance scanning
  • continuously monitor bucket exposure

🔍 Tools Used to Audit S3 Security

Popular Tools

Tool Purpose
AWS Config Compliance monitoring
Security Hub Security findings
CloudTrail Audit logging
ScoutSuite Cloud auditing
Prowler AWS security assessment

☁️ Real Industry Practice

Most mature companies follow:

Private by default
Public only when necessary

This is the golden rule of cloud storage security.

🎯 Final Thoughts

S3 looks simple initially.

But in real enterprise environments:

  • permissions
  • policies
  • encryption
  • compliance
  • auditing

become extremely important.

A single misconfigured bucket can expose:

  • millions of records
  • customer identities
  • internal infrastructure data

That’s why:

understanding S3 security is one of the most important cloud skills.

I Built an AI Coding Assistant Because I Got Tired of Context Switching

AI coding tools are everywhere. And yet, my workflow kept breaking the same way:

Write code → leave VS Code → open browser → ask AI → copy response → come back → repeat.

Every switch costs focus. Those interruptions add up.

I wanted an assistant that lived inside the editor and could handle real development work — not just answer questions. So I built Coda AI.

What Is Coda AI?

Coda AI is a VS Code extension with:

  • 💬 In-editor chat
  • 🤖 Autonomous agent workflows
  • ✍️ Inline completions
  • 🔌 Support for multiple model providers

Here’s what I focused on when building it.

⚡ Workspace-Aware Context

Coda understands your project structure — not just the file you have open. It uses your actual codebase as context, so responses are grounded in your real code, not generic examples.

Agent Mode: Hand It Real Work

Instead of asking:

“How do I fix this?”

You hand it a task and let it run:

  • Refactor a module
  • Write tests
  • Fix bugs
  • Run builds
  • Update files across the codebase
  • Investigate issues

The agent can inspect code, propose changes, execute commands, and iterate — with approval prompts for sensitive actions so you stay in control.

🔌 Provider Flexibility — No Lock-In

One thing that consistently bothered me about AI tooling: you adopt a workflow, then you’re stuck with one provider.

Coda supports:

Provider Type
OpenAI Cloud
Claude (Anthropic) Cloud
Gemini (Google) Cloud
DeepSeek Cloud
Groq Cloud
Amazon Bedrock Cloud
OpenRouter Cloud (aggregator)
Ollama Fully local

If a better model ships tomorrow, your workflow doesn’t need to change.

🧩 MCP Support — Connect Any Tool

This was one of my favourite parts to build.

Coda supports the Model Context Protocol (MCP), so the agent can work with external tools automatically:

  • GitHub
  • Filesystem
  • Documentation
  • Custom workflows

No hardcoded integrations. You plug in what you need.

What Building This Taught Me

Developer tooling isn’t only about adding intelligence. It’s about reducing friction.

The fewer times you leave flow state, the better the tool.

Intelligence without integration is just another tab to switch to.

Try It & Share Feedback

Coda is still early, and I’m actively improving it.

If you’re a developer, I’d genuinely value your input:

What would make an AI coding assistant actually useful in your day-to-day workflow?

Drop your thoughts in the comments — critical feedback welcome.

🔗 Install on the VS Code Marketplace

Built by a developer who got tired of switching tabs. Shipping, not just talking about it.

“I Stopped Choosing Between Claude Code and Codex. I Put Both in One Chat Window”

Every “Claude Code vs Codex” comparison eventually runs into the same boring truth:

I do not want to pick one forever.

Some tasks feel better in Claude Code. Some feel better in Codex. Some days one account is rate-limited, one model is cheaper, or one runtime is already holding the context I need.

The annoying part is not choosing the better agent.

The annoying part is switching surfaces every time I change my mind.

The workflow I wanted

I wanted one local chat window where I could do this:

Use Codex for this task.
Continue that same runtime.
Switch to Claude Code for the next one.
Ask the assistant to plan first.
Go back to direct runtime mode.

That sounds like a UI problem, but it is really a control problem.

There are two different things happening:

  1. direct runtime work, where the next message should go straight to Claude Code or Codex
  2. assistant-mediated work, where a supervisor decides whether to answer, ask a question, or delegate to a runtime

If those two modes are not explicit, the chat window turns into a trap. A short follow-up like:

make it smaller

can either mean:

  • continue the active Codex runtime
  • ask the product assistant
  • start a new Claude Code task
  • answer a pending approval

Guessing wrong here is exactly how coding agents become frustrating.

So I made direct runtime the default

In CliGate, the chat UI conversation now defaults to direct runtime mode.

That was a deliberate choice.

Most of the time, when I am using a coding agent, I do not want an assistant to intercept every message and “think about what I meant.” I want the current runtime to continue until I explicitly ask for something else.

There is a test that pins this behavior:

test('ChatUiConversationStore defaults new chat-ui conversations to direct-runtime control mode', () => {
  const conversation = conversationStore.findOrCreateBySessionId('chat-ui-default-direct-runtime-1');

  assert.equal(conversation.metadata?.assistantCore?.mode, 'direct-runtime');
  assert.equal(conversation.metadata?.assistantCore?.controlMode, 'direct-runtime');
});

That means a normal chat message does not automatically become “assistant work.” It stays on the runtime path.

The two commands that made the UI usable

I ended up with a small mode switch instead of another complicated settings panel:

/cligate
/runtime

The mode parser is intentionally tiny:

const cligateMatch = trimmed.match(/^/cligate(?:s+(.+))?$/is);
if (cligateMatch) {
  return {
    command: 'cligate',
    args: String(cligateMatch[1] || '').trim()
  };
}

if (/^/runtime$/i.test(trimmed)) {
  return {
    command: 'runtime',
    args: ''
  };
}

The behavior is:

  • /cligate enters assistant mode
  • /cligate <task> runs one assistant-mediated task
  • /runtime exits assistant mode and returns to direct runtime routing

That one escape hatch matters.

When I am done asking the assistant to plan or coordinate, I want the next message to go back to the active Claude Code or Codex session without ceremony.

The route now decides before touching the runtime

The chat route first gives the assistant mode service a chance to handle the message.

If assistant mode is not active and there is no /cligate command, it returns null, and the message goes down the normal runtime path:

const assistantResult = await this.assistantModeService.maybeHandleMessage({
  conversation,
  text,
  defaultRuntimeProvider,
  cwd,
  model,
  executionMode: assistantExecutionMode,
  onBackgroundResult
});

if (assistantResult) {
  return {
    ...assistantResult,
    previousSessionId: conversation.activeRuntimeSessionId || null,
    conversation: assistantResult.conversation || this.conversationStore.get(conversation.id)
  };
}

Only after that does the service route directly to the runtime:

const result = await this.messageService.routeUserMessage({
  message: { text },
  conversation,
  defaultRuntimeProvider,
  cwd,
  model,
  metadata: {
    assistantMode: getAssistantControlMode(conversation),
    source: {
      kind: 'chat-ui',
      sessionId: String(sessionId || ''),
      conversationId: conversation.id
    }
  }
});

That separation is the whole point.

The assistant does not get to hijack direct runtime messages just because it exists.

Why this is better than a “smart” default

I tried to make the assistant helpful.

Then I realized “helpful” is dangerous in a coding workflow.

If a runtime is waiting for input, the least surprising thing is to send input to that runtime. If a task has a pending approval, the least surprising thing is to resolve that approval. If the user explicitly types /cligate, then the assistant can step in.

The result feels less magical, but much easier to trust.

For example:

fix the failing unit test

can start a Codex runtime.

Then:

try the simpler patch

continues that runtime.

Then:

/cligate compare this failure with the last run before continuing

lets the assistant reason over the situation.

Then:

/runtime

puts the conversation back on the direct runtime path.

That is the loop I wanted.

Background runs needed their own guardrail

The other bug showed up after I made assistant runs asynchronous in the chat UI.

If an assistant-mediated task starts a runtime and returns later, the UI needs to persist the background result. But it must not append stale output from an older assistant run after the user has already started a newer one.

So the route records the pending assistant run ID:

if (result?.type === 'assistant_run_accepted' && result?.assistantRun?.id) {
  chatUiConversationStore.patch(conversation.id, {
    metadata: {
      ...(conversation.metadata || {}),
      uiChatPendingAssistantRunId: String(result.assistantRun.id || '').trim()
    }
  });
}

And the background callback refuses stale results:

if (getPendingUiAssistantRunId(conversation) !== backgroundRunId) {
  return;
}

That is not glamorous, but it prevents a very real UI bug:

  1. start an assistant task
  2. start another task before the first one finishes
  3. watch the old answer appear under the new task

No thanks.

The model override bug was another small footgun

There was one more detail that mattered for a mixed Claude Code / Codex chat surface.

The normal chat UI has a local model selector. Runtime routing has its own provider semantics. If I let the local chat model override leak into runtime routing, I could accidentally send something like gpt-5.4 into a Claude Code runtime path where that was not the user’s intent.

So for local chat-ui runtime messages, the route deliberately ignores the UI chat model override:

const runtimeModelOverride = isExternalConversation ? String(model || '') : '';

There is a test for that too:

assert.equal(captured[0].model, '');
assert.equal(captured[0].defaultRuntimeProvider, 'claude-code');

That tiny rule saved the UI from pretending that “selected chat model” and “runtime provider” are the same concept.

They are not.

What the setup looks like

Start CliGate:

npx cligate@latest start

Open the dashboard:

http://localhost:8081

Then use the Chat page as the control surface:

  • choose Codex or Claude Code as the runtime provider
  • send a normal task to start direct runtime work
  • keep sending follow-ups to continue that runtime
  • use /cligate when you want assistant-mediated planning or delegation
  • use /runtime to return to the direct runtime path

That is the workflow I wanted from the beginning.

Not “which terminal agent wins?”

More like:

“Can I keep both available without rebuilding my workflow around either one?”

The lesson

The current wave of AI coding tools makes comparisons tempting.

Claude Code vs Codex. Codex vs Gemini CLI. Terminal agent vs IDE agent.

Those comparisons are useful, but they miss the day-to-day problem:

developers do not just choose tools. They move between them.

For me, the useful abstraction was not a smarter chatbot.

It was a chat control surface with explicit ownership:

  • direct runtime by default
  • assistant mode only when requested
  • sticky runtime continuation
  • stale background result protection
  • no accidental model override leaking into runtime work

That made Claude Code and Codex feel less like competing terminals and more like two workers behind the same local desk.

If you want to inspect the implementation, the project is here:

CliGate on GitHub

I’m curious how other people are handling this. Are you choosing one coding agent, or are you building a workflow that lets several of them coexist?

Terraforming a Production-Lite GCP Web Platform: MIG, Cloud NAT, Load Balancer, and Private Backends

Hi! After building my first Terraform artifact, The GCP Foundation Lite, I wanted to move one layer higher.

I wanted to answer a more complex question:

Can I provision infrastructure for an actual web platform?

Thus, I created this project titled “Production-Lite GCP Web Platform” using Terraform.

This project provisions:

  • custom VPC network
  • app subnet
  • reserved database subnet
  • map-based firewall rules
  • Cloud NAT
  • custom service account
  • regional Managed Instance Group
  • instance template
  • startup script
  • HTTP health check
  • backend service
  • external HTTP load balancer
  • remote Terraform state
  • reusable Terraform modules

The goal was not to create a full enterprise platform, but to create a small but production-shaped infra pattern that can run an application.

But, before i go even further, do check my github repository at terraform-gcp-production-lite-web-platform

Why I Built This

In my first artifact, I focused on the foundation layer.

That project included:

  • remote Terraform state
  • versioned GCS state bucket
  • custom VPC
  • role-based subnets
  • firewall rules
  • service accounts
  • IAM bindings
  • reusable modules

That was useful because it helped me understand how to create the base layer of a Google Cloud environment.

But a foundation alone does not run an application.

So for this second artifact, I wanted to build something closer to a real web platform.

The objective was to move from this:

Terraform creates the foundation.

To this:

Terraform provisions infrastructure that can serve application traffic.

What This Project Builds

This project creates:

  • custom VPC network
  • application subnet
  • reserved database subnet
  • firewall rules
  • Cloud Router
  • Cloud NAT
  • custom application service account
  • instance template
  • regional Managed Instance Group
  • HTTP health check
  • backend service
  • external HTTP load balancer
  • global forwarding rule
  • startup script
  • simple web application endpoint
  • remote state in GCS

The application VMs are private.

They do not have external IP addresses.

Users access the application through the external HTTP load balancer.

Outbound internet access from the private VMs is handled through Cloud NAT.

High-Level Architecture

The high-level architecture is:

User
  ↓
External HTTP Load Balancer
  ↓
Backend Service
  ↓
Regional Managed Instance Group
  ↓
Private Application VM
  ↓
Application Endpoint

For outbound access:

Private Application VM
  ↓
Cloud NAT
  ↓
Internet

The important point is this:

Inbound traffic enters through the load balancer.
Outbound traffic leaves through Cloud NAT.
The backend VM does not need a public IP address.

Architecture Diagram

User / Browser
      |
      v
External HTTP Load Balancer
      |
      v
Target HTTP Proxy
      |
      v
URL Map
      |
      v
Backend Service
      |
      v
Regional Managed Instance Group
      |
      v
Private App VM(s)
      |
      v
Application running from startup script

Private App VM(s)
      |
      v
Cloud NAT
      |
      v
Outbound Internet

What Production-Lite Means

For this project, production-lite means the infrastructure follows production-style patterns without trying to become a full enterprise platform.

This project includes:

private backend instances
load balancer entry point
health checks
Managed Instance Group
Cloud NAT
service account separation
firewall rules
remote state
Terraform modules

But this version does not include:

HTTPS
custom domain
Cloud Armor
Cloud SQL
Secret Manager
CI/CD
multi-region deployment
blue-green deployment
Kubernetes

Those features are important, but I intentionally deferred them.

The goal of v1.0 is to keep the scope focused:

Can I provision a production-shaped HTTP web platform with private backends?

Why Private Backend Instances Matter

One of the most important design choices in this project is that the backend VMs do not have external IP addresses.

In the instance template, the network interface does not include an access_config block.

Conceptually, the pattern is:

network_interface {
  subnetwork = var.subnetwork_self_link

  # No access_config block.
  # This intentionally creates VMs without external IP addresses.
}

This means the VM is not directly exposed to the internet.

Instead, application traffic must go through the load balancer.

That is a better pattern than exposing a VM directly with a public IP address.

A direct public VM might be fine for a quick test, but for an application platform, I want a cleaner entry point:

Internet
  -> Load Balancer
  -> Backend Service
  -> Private VM

Why Cloud NAT Is Needed

Because the backend VMs are private, they do not have direct outbound internet access through an external IP.

However, the VM still needs outbound access for operational tasks such as:

  • running apt-get update
  • installing packages
  • downloading dependencies
  • calling external services
  • bootstrapping the application during startup

This is where Cloud NAT is useful.

Cloud NAT allows private resources to initiate outbound internet connections without assigning public IP addresses to those resources.

In this project, Cloud NAT is created using:

  • Cloud Router
  • Cloud NAT gateway
  • selected subnet configuration

The important improvement from my earlier lab is that I do not need to NAT every subnet.

For this artifact, the app subnet receives NAT.

The reserved database subnet does not receive NAT by default.

This is the intended posture:

app subnet -> Cloud NAT enabled
db subnet  -> Cloud NAT not enabled by default

That separation is small, but architecturally meaningful.

Why I Used a Managed Instance Group

A standalone VM is simpler.

But a standalone VM does not really show platform thinking.

For this artifact, I used a regional Managed Instance Group.

The MIG uses:

  • instance template
  • target size
  • named port
  • autohealing policy
  • health check

The difference is important.

A standalone VM says:

I created a server.

A Managed Instance Group says:

I defined how application instances should be created, managed, replaced, and checked.

That is a stronger infrastructure pattern.

Why I Used an External HTTP Load Balancer

The external HTTP load balancer acts as the public entry point.

The load balancer connects to the backend service, and the backend service connects to the Managed Instance Group.

The request flow is:

User
  -> Global forwarding rule
  -> Target HTTP proxy
  -> URL map
  -> Backend service
  -> Managed Instance Group
  -> Application VM

This is more realistic than opening port 80 directly on a public VM.

It also allows me to test important infrastructure concepts:

  • backend services
  • health checks
  • named ports
  • firewall rules for health check probes
  • load-balanced application access

Why Health Checks Matter

The load balancer needs to know whether the backend instances are healthy.

For that, I use an HTTP health check.

The application exposes:

GET /
GET /healthz
GET /metadata

The /healthz endpoint is used by the health check.

This is better than using / as the health check path because / is usually a user-facing route, while /healthz is explicitly meant for machine health checking.

The expected response is simple:

ok

A simple health endpoint is enough for this version.

The goal is not to build a complex application.

The goal is to prove that the infrastructure can route traffic to a healthy backend.

Application Endpoints

The sample application exposes three endpoints.

Endpoint Purpose
/ Root endpoint
/healthz Health check endpoint
/metadata Instance information endpoint

The root endpoint returns:

Hi from Terraform GCP Production-Lite Platform

The health endpoint returns:

ok

The metadata endpoint returns information such as:

{
  "service": "terraform-gcp-production-lite-web-platform",
  "environment": "dev",
  "version": "1.0.0",
  "hostname": "dev-web-mig-xxxx"
}

The application is intentionally small.

Terraform and infrastructure design are the focus.

Repository Structure

The repository structure is:

terraform-gcp-production-lite-web-platform/
├── README.md
├── .gitignore
├── versions.tf
├── providers.tf
├── backend.tf.example
├── main.tf
├── variables.tf
├── outputs.tf
├── terraform.tfvars.example
├── locals.tf
├── docs/
│   ├── architecture.md
│   ├── deployment-runbook.md
│   ├── operations-runbook.md
│   ├── verification.md
│   ├── design-decisions.md
│   └── version-roadmap.md
├── scripts/
│   └── startup.sh
└── modules/
    ├── network/
    │   ├── main.tf
    │   ├── variables.tf
    │   └── outputs.tf
    ├── iam/
    │   ├── main.tf
    │   ├── variables.tf
    │   └── outputs.tf
    ├── nat/
    │   ├── main.tf
    │   ├── variables.tf
    │   └── outputs.tf
    ├── compute/
    │   ├── main.tf
    │   ├── variables.tf
    │   └── outputs.tf
    └── load-balancer/
        ├── main.tf
        ├── variables.tf
        └── outputs.tf

There are five main modules:

network
iam
nat
compute
load-balancer

Each module has a specific responsibility.

Module Responsibilities

Module Responsibility
network VPC, subnets, firewall rules
iam Service accounts and IAM role bindings
nat Cloud Router and Cloud NAT
compute Instance template and Managed Instance Group
load-balancer HTTP load balancer resources

The root module connects them together.

The root module should answer:

What components make up this platform?
How are those components connected?

The child modules should answer:

How is each infrastructure area implemented?

This separation makes the repository easier to read.

Root Module Composition

The root main.tf composes the platform.

At a high level, the order is:

network
iam
nat
health check
compute
load balancer
IAP IAM bindings

This order makes sense because:

Compute needs network and IAM.
NAT needs network.
The load balancer needs the instance group.
IAP access needs IAM and firewall rules.

The root module should not contain all low-level resources.

It should orchestrate modules.

Network Module

The network module creates:

  • VPC
  • subnets
  • firewall rules

The VPC is created as a custom mode VPC:

resource "google_compute_network" "this" {
  name                    = local.final_network_name
  auto_create_subnetworks = false
  routing_mode            = "REGIONAL"
}

I use:

auto_create_subnetworks = false

because I want explicit control over subnet ranges.

This is cleaner than relying on automatically created subnets.

Map-Based Subnets

Instead of hardcoding each subnet as a separate resource, I define subnets as a map.

Example:

subnets = {
  app = {
    cidr_range            = "10.80.1.0/24"
    private_google_access = true
    role                  = "application"
  }

  db = {
    cidr_range            = "10.80.2.0/24"
    private_google_access = true
    role                  = "database-reserved"
  }
}

The network module then creates subnets using for_each.

Conceptually:

resource "google_compute_subnetwork" "subnets" {
  for_each = var.subnets

  name                     = "${var.environment}-${each.key}-subnet"
  region                   = coalesce(each.value.region, var.region)
  network                  = google_compute_network.this.id
  ip_cidr_range            = each.value.cidr_range
  private_ip_google_access = each.value.private_google_access
}

This is more flexible than writing:

resource "google_compute_subnetwork" "app" {
  ...
}

resource "google_compute_subnetwork" "db" {
  ...
}

If I want to add another subnet later, I can add another map entry.

For example:

cache = {
  cidr_range = "10.80.3.0/24"
  role       = "cache"
}

The module does not need to change.

Map-Based Firewall Rules

The network module also creates firewall rules from a map.

Example:

firewall_rules = {
  allow-lb-health-check = {
    description   = "Allow Google Cloud load balancer health checks and proxy traffic."
    source_ranges = ["35.191.0.0/16", "130.211.0.0/22"]
    target_tags   = ["web-backend"]

    allow = [
      {
        protocol = "tcp"
        ports    = ["80"]
      }
    ]
  }

  allow-iap-ssh = {
    description   = "Allow SSH to private backend instances through IAP."
    source_ranges = ["35.235.240.0/20"]
    target_tags   = ["web-backend"]

    allow = [
      {
        protocol = "tcp"
        ports    = ["22"]
      }
    ]
  }
}

The module creates firewall rules using for_each and dynamic allow blocks.

Conceptually:

resource "google_compute_firewall" "ingress_rules" {
  for_each = var.firewall_rules

  name          = "${var.environment}-${each.key}"
  network       = google_compute_network.this.name
  description   = each.value.description
  direction     = "INGRESS"
  source_ranges = each.value.source_ranges
  target_tags   = each.value.target_tags

  dynamic "allow" {
    for_each = each.value.allow

    content {
      protocol = allow.value.protocol
      ports    = allow.value.ports
    }
  }
}

I prefer this pattern because traffic policy becomes data-driven.

The module does not need to know every possible firewall rule.

It only needs to know how to create firewall rules from structured input.

Firewall Rules Used

For this version, I use three main firewall rules:

Rule Purpose
allow-lb-health-check Allows Google load balancer and health check traffic to backend VMs
allow-iap-ssh Allows SSH through IAP TCP forwarding
allow-internal Allows internal traffic inside the platform CIDR

The important security decision is that I do not open SSH to:

0.0.0.0/0

Instead, SSH access is designed around IAP.

IAM Module

The IAM module creates service accounts.

For this artifact, the main service account is the application VM service account.

Example input:

service_accounts = {
  app = {
    account_id    = "dev-prod-lite-app-sa"
    display_name  = "Production Lite App Service Account"
    description   = "Service account used by private application VM instances."
    project_roles = [
      "roles/logging.logWriter",
      "roles/monitoring.metricWriter"
    ]
  }
}

The service account is attached to the application instances.

This is cleaner than using the default Compute Engine service account.

It also makes the identity of the workload explicit.

NAT Module

The NAT module creates:

  • Cloud Router
  • Cloud NAT

In my earlier lab, Cloud NAT was applied to all subnets.

For this artifact, I wanted a more intentional design.

So NAT is applied only to selected subnet keys.

Example:

nat_subnet_keys = ["app"]

That means:

app subnet gets outbound internet through Cloud NAT
db subnet does not get NAT by default

This is a small design improvement, but it shows better network intent.

The app tier needs outbound access for package installation and application operations.

The reserved database tier should be more restricted.

Compute Module

The compute module creates:

  • instance template
  • regional Managed Instance Group
  • named port
  • autohealing policy

The instance template defines:

  • machine type
  • boot disk
  • network interface
  • startup script
  • service account
  • network tags

The important part is the network interface.

network_interface {
  subnetwork = var.subnetwork_self_link

  # No access_config block.
}

This keeps the VM private.

The MIG then uses the instance template:

resource "google_compute_region_instance_group_manager" "this" {
  name               = "${var.environment}-${var.mig_name}"
  region             = var.region
  base_instance_name = "${var.environment}-${var.mig_name}"
  target_size        = var.target_size

  version {
    instance_template = google_compute_instance_template.this.self_link
  }

  named_port {
    name = "http"
    port = var.app_port
  }

  auto_healing_policies {
    health_check      = var.health_check_self_link
    initial_delay_sec = 120
  }
}

The named port is important because the backend service uses it to send traffic to the correct backend port.

Load Balancer Module

The load balancer module creates:

  • global IP address
  • backend service
  • URL map
  • target HTTP proxy
  • global forwarding rule

The backend service connects the load balancer to the MIG.

Conceptually:

resource "google_compute_backend_service" "this" {
  name                  = "${var.environment}-${var.lb_name}-backend"
  protocol              = "HTTP"
  port_name             = "http"
  load_balancing_scheme = "EXTERNAL_MANAGED"
  timeout_sec           = 30

  health_checks = [
    var.health_check_self_link
  ]

  backend {
    group           = var.backend_instance_group
    balancing_mode  = "UTILIZATION"
    capacity_scaler = 1.0
  }
}

The load balancer then exposes the service through a global forwarding rule.

For v1.0, I use HTTP on port 80.

HTTPS will come later in v1.1.

Startup Script

The startup script bootstraps the application when the VM starts.

The script installs dependencies, creates the application files, and starts the service.

A simplified version of the application behavior is:

GET /         -> returns a simple message
GET /healthz -> returns ok
GET /metadata -> returns hostname and version information

The important operational improvement is that the application should run as a systemd service.

That is better than running a background process with &.

With systemd, I can check:

sudo systemctl status prod-lite-app

And view logs:

sudo journalctl -u prod-lite-app --no-pager -n 50

Remote State

Like the first artifact, this project uses a GCS backend for Terraform state.

Example backend:

terraform {
  backend "gcs" {
    bucket = "YOUR_TERRAFORM_STATE_BUCKET"
    prefix = "terraform-gcp-production-lite-web-platform/v1"
  }
}

The state path becomes something like:

gs://YOUR_TERRAFORM_STATE_BUCKET/terraform-gcp-production-lite-web-platform/v1/default.tfstate

Using remote state is important because this project is no longer a tiny one-file local experiment.

It has multiple modules and multiple cloud resources.

Remote state gives the project a more realistic workflow.

Git Safety

I do not commit real .tfvars files.

The repository includes:

terraform.tfvars.example

But ignores:

terraform.tfvars

The .gitignore includes:

.terraform/
*.tfstate
*.tfstate.*
*.tfvars
*.tfplan
crash.log
.DS_Store

This avoids committing local values, real project IDs, or state files.

Running the Project

The execution flow is:

1. Configure backend
2. Configure terraform.tfvars
3. Run terraform fmt
4. Run terraform init
5. Run terraform validate
6. Run terraform plan
7. Run terraform apply
8. Verify the load balancer and backend health

Configure Backend

cp backend.tf.example backend.tf

Then edit the bucket name.

Configure Variables

cp terraform.tfvars.example terraform.tfvars

Then edit:

project_id      = "your-gcp-project-id"
admin_principal = "user:[email protected]"

Run Terraform

terraform fmt -recursive
terraform init
terraform validate
terraform plan
terraform apply

Expected Output

After deployment, Terraform should output values such as:

network_name
subnets
firewall_rules
cloud_nat_name
cloud_router_name
health_check_name
mig_name
mig_instance_group
load_balancer_ip
load_balancer_url
curl_test_command
curl_health_check_command
platform_summary

The most important output is:

load_balancer_url

That URL is used to test the application.

Verification

After deployment, I can test the root endpoint:

curl -i http://LOAD_BALANCER_IP

Expected response:

Hi from Terraform GCP Production-Lite Platform

Test the health endpoint:

curl -i http://LOAD_BALANCER_IP/healthz

Expected response:

ok

Test the metadata endpoint:

curl -i http://LOAD_BALANCER_IP/metadata

Expected response:

{
  "service": "terraform-gcp-production-lite-web-platform",
  "environment": "dev",
  "version": "1.0.0",
  "hostname": "dev-web-mig-xxxx"
}

Verifying the Infrastructure

Verify the VPC:

gcloud compute networks list

Verify the subnets:

gcloud compute networks subnets list

Verify firewall rules:

gcloud compute firewall-rules list

Verify Cloud NAT:

gcloud compute routers nats list 
  --router=dev-nat-router 
  --region=asia-southeast2

Verify the Managed Instance Group:

gcloud compute instance-groups managed list

Verify backend health:

gcloud compute backend-services get-health BACKEND_SERVICE_NAME --global

Verify the application VM does not have an external IP:

gcloud compute instances list

This is one of the most important checks.

The application should be reachable through the load balancer, not directly through a public VM IP.

Accessing the Private VM Through IAP

Since the VM has no external IP, direct SSH is not available.

Instead, I use IAP TCP forwarding.

Example:

gcloud compute ssh INSTANCE_NAME 
  --zone=asia-southeast2-a 
  --tunnel-through-iap

This requires:

  • IAM permission for IAP tunnel access
  • OS Login role
  • firewall rule allowing IAP source range
  • correct target tag on the VM

The firewall source range for IAP TCP forwarding is:

35.235.240.0/20

This is better than opening SSH to the public internet.

Troubleshooting Notes

Load Balancer Returns 502

Possible causes:

  • application failed to start
  • health check path is wrong
  • firewall rule does not allow health check probes
  • target tag mismatch
  • named port mismatch
  • backend service points to the wrong instance group

Useful commands:

gcloud compute backend-services get-health BACKEND_SERVICE_NAME --global
sudo systemctl status prod-lite-app
sudo journalctl -u prod-lite-app --no-pager -n 100

VM Cannot Install Packages

Possible cause:

Cloud NAT is not configured correctly.

Useful check:

curl https://example.com

Run that from inside the VM.

IAP SSH Fails

Possible causes:

  • missing IAP role
  • missing OS Login role
  • missing service account user role
  • missing IAP firewall rule
  • wrong VM network tag

Check firewall rules:

gcloud compute firewall-rules list --filter="name~iap"

Important Design Decisions

1. Use private backend VMs

The backend application instances do not have external IP addresses.

This reduces direct exposure and forces traffic through the load balancer.

2. Use Cloud NAT

The private VMs still need outbound access.

Cloud NAT allows that without assigning public IPs to the VMs.

3. Use map-based subnet definitions

Subnets are defined through a map so the network module stays reusable.

Adding another subnet should not require another resource block.

4. Use map-based firewall rules

Firewall rules are also defined through a map.

This keeps ingress policy data-driven and easier to extend.

5. Use a Managed Instance Group

The application tier is managed as a group based on an instance template.

This is more production-shaped than a standalone VM.

6. Use HTTP only in v1.0

HTTPS is intentionally deferred to v1.1.

The v1.0 goal is to prove:

private backends
Cloud NAT
MIG
health checks
HTTP load balancing
firewall rules
remote state

7. Reserve a DB subnet without provisioning a database

The DB subnet demonstrates tiered network design.

A database is not provisioned in v1.0 because this artifact focuses on the web platform infrastructure.

What I Learned

This artifact helped me understand that application infrastructure is not only about creating a VM.

A proper platform needs several parts to work together:

networking
identity
firewall rules
NAT
compute lifecycle
health checks
load balancing
state management
documentation

The most interesting part for me was seeing how small configuration details affect the whole platform.

For example:

  • if the firewall source range is wrong, the load balancer cannot reach the backend
  • if the health check path is wrong, the backend becomes unhealthy
  • if Cloud NAT is missing, private VMs may fail during startup
  • if the MIG named port does not match the backend service, traffic may not route correctly
  • if SSH is opened to 0.0.0.0/0, the design becomes weaker

This project made me appreciate that infrastructure is a system.

Each component has to be designed with the others in mind.

What I Intentionally Did Not Add

I intentionally did not add HTTPS in this version.

I also did not add:

  • Cloud Armor
  • Cloud SQL
  • Secret Manager
  • CI/CD
  • custom domain
  • blue-green deployment
  • autoscaling policy
  • multi-region deployment

Those are useful, but I want this artifact to stay focused.

The purpose of v1.0 is to build the core web platform first.

Next Step

The next version will be:

v1.1 — HTTPS and Custom Domain

That version should add:

  • Google-managed SSL certificate
  • custom domain
  • HTTPS target proxy
  • global forwarding rule on port 443
  • optional HTTP-to-HTTPS redirect

After that, I want to continue with:

v1.2 — Security Hardening
v2.0 — Terraform CI/CD with GitHub Actions and Workload Identity Federation
v2.1 — Drift, Import, and State Recovery
v3.0 — Database and Secrets

References

Terraform GCS backend:
https://developer.hashicorp.com/terraform/language/backend/gcs

Google Cloud — Store Terraform state in Cloud Storage:
https://cloud.google.com/docs/terraform/resource-management/store-state

Google Cloud — External Application Load Balancer overview:
https://cloud.google.com/load-balancing/docs/https

Google Cloud — Terraform examples for external Application Load Balancers:
https://cloud.google.com/load-balancing/docs/https/ext-http-lb-tf-module-examples

Google Cloud — Cloud NAT overview:
https://cloud.google.com/nat/docs/overview

Google Cloud — IAP TCP forwarding:
https://cloud.google.com/iap/docs/using-tcp-forwarding

Building A Telegram Bot-to-Bot Communication Showcase With TypeScript

App Flow

Telegram’s Bot-to-Bot Communication feature changes what a Telegram group can be. A group no longer has to be a place where humans talk and bots merely respond. It can become a visible multi-agent workspace where bots coordinate through the same messages humans can read.

This project, Telegram Bot-to-Bot Debate Club, is a working demonstration of that idea.

The demo has four Telegram bots:

  • DebaterRedBot argues for a proposition.
  • DebaterBlueBot argues against it.
  • SocraticBot questions the weakest claim.
  • JudgeBot delivers a final verdict.

The important part is not that the bots debate. The important part is how they coordinate: every transition is a real Telegram group message delivered through Telegram’s bot-to-bot mechanism. There is no hidden in-process bot registry, no direct method call between bots, and no local handoff fallback.

Built by Harish Kotra · Checkout my other builds

Why Bot-to-Bot Communication Matters

Most chatbots are designed around a human-to-bot model:

Human -> Bot -> Human

But many useful workflows are multi-role:

Human -> Intake Bot -> Research Bot -> Reviewer Bot -> Finalizer Bot

Without bot-to-bot delivery, developers often have to fake this with server-side orchestration. That works, but users cannot see the real control flow. The group chat becomes a UI facade over a hidden backend.

Telegram’s Bot-to-Bot Communication enables a different model:

Bot A posts in the group.
Telegram delivers Bot A's message to Bot B.
Bot B decides whether to respond.
The whole workflow remains visible in the chat.

That visibility is valuable. It makes debugging easier, makes automation auditable, and lets humans understand why a bot responded.

The Showcase App

The debate club is intentionally small but realistic:

  1. A human starts with /debate <topic>.
  2. Red posts a FOR argument and mentions Blue.
  3. Blue receives Red’s bot-authored message through Telegram and replies AGAINST.
  4. Blue mentions Socratic.
  5. Socratic receives Blue’s bot-authored message through Telegram and challenges one debater.
  6. The targeted debater answers.
  7. After the configured exchange limit, a bot sends /verdict@JudgeBot.
  8. Judge receives that bot-authored command through Telegram and posts the verdict.

App Flow

System Architecture

All four bots run in a single Node.js process, but each bot is a separate Telegram bot identity with its own token.

System Architecture

The single-process design keeps the showcase easy to run:

npm run dev

The important point is that shared process does not mean hidden coordination. The process shares state for safety, but bot-to-bot progression still depends on Telegram delivering bot-authored messages.

The Visible Control Plane

Red’s job is to end its reply by tagging Blue:

@YourClubDebaterBlueBot what do you say?

Blue decides whether to respond by inspecting the Telegram message it received:

shouldRespond(msg: TelegramBot.Message): boolean {
  if (this.isSelfMessage(msg)) {
    return false;
  }

  return this.mentionsThisBot(msg);
}

That tiny check is the heart of the showcase. Blue is not called by Red. Blue is reacting to a Telegram update.

The same rule applies to the verdict:

/verdict@YourClubJudgeBot

Judge only responds to the visible Telegram command:

const isVerdictCommand =
  text.startsWith(`/verdict@${CANONICAL_USERNAMES.judge.toLowerCase()}`) ||
  text === '/verdict';

return hasDebateToJudge && isVerdictCommand;

The final trigger is not a hidden local state transition. It is a bot-to-bot Telegram command in the group.

Why Shared State Still Exists

Bot-to-Bot Communication gives you delivery. It does not give you workflow semantics.

In a group with four bots, every bot can receive many of the same group messages. Without turn control, multiple bots may answer at the wrong time or create loops.

The project uses a shared DebateState singleton:

export interface DebateState {
  topic: string | null;
  isActive: boolean;
  isPaused: boolean;
  roundNumber: number;
  debaterRoundNumber: number;
  totalMessageCount: number;
  socraticDepth: number;
  messages: DebateMessage[];
  lastReplyAt: Record<string, number>;
  seenMessageIds: Set<number>;
  judgeHasFired: boolean;
  verdictRequested: boolean;
  debateId: number;
  expectedResponder: BotRole | null;
  pendingSocraticTarget: BotRole | null;
  disqualified: Set<string>;
  steerInstruction: string | null;
  steerUntilRound: number | null;
}

The key field is expectedResponder.

if (
  this.role !== 'judge' &&
  this.state.expectedResponder &&
  this.state.expectedResponder !== this.role
) {
  return;
}

Telegram delivers messages. The app decides whose turn it is.

Counting The Right Thing

One subtle bug in multi-agent debates is counting every bot message as a round. That makes Socratic questions accidentally shorten the debate.

This project tracks two counters:

  • totalMessageCount: every bot debate message.
  • debaterRoundNumber: completed Red/Blue exchanges.

The Judge is triggered after the intended debater exchange limit, not after Socratic questions.

The flow is:

Red -> Blue -> Socratic -> targeted debater -> other debater -> Socratic ...

When the max debater exchange limit is reached, Socratic can still ask one final question, and the targeted debater gets one final answer before the Judge command is sent.

That makes the demo feel like a coherent conversation instead of a timer.

Loop Guarding Bot-to-Bot Workflows

Bot-to-bot systems need guardrails. This project has a LoopGuard that handles:

  • duplicate updates,
  • per-bot cooldowns,
  • pair-depth limits.

Deduplication is per receiving bot for normal bot-to-bot messages:

const seenKey = `${botUsername}:${messageId}`;
if (this.seenMessageKeys.has(seenKey)) return false;
this.seenMessageKeys.add(seenKey);

That detail matters. If dedupe were global, the first bot to see a group message could prevent the intended next bot from processing it.

Moderator commands use global dedupe because only one bot should handle /debate, /pause, /resume, and similar commands.

The LLM Layer

The LLM client is deliberately provider-neutral. It uses raw fetch against an OpenAI-compatible /v1/chat/completions endpoint:

const response = await fetch(this.completionsUrl, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${this.apiKey}`,
    ...this.extraHeaders,
  },
  body: JSON.stringify({ ...req, ...this.extraBody }),
});

That keeps the project compatible with:

  • LM Studio,
  • OpenRouter,
  • Featherless.ai,
  • local OpenAI-compatible servers,
  • hosted inference gateways.

The expected response is simple:

const content = choice?.message?.content;

if (typeof content !== 'string' || content.trim().length === 0) {
  throw new Error(this.describeEmptyContent(rawBody, choice, json.usage));
}

For LM Studio, a non-reasoning instruct model is best for Judge:

LLM_MODEL_JUDGE=mistralai/Mistral-7B-Instruct-v0.3
LLM_MAX_TOKENS=700

Reasoning models can spend their whole budget on reasoning_content and return no final answer. The app has a deterministic fallback verdict so the Telegram workflow still closes cleanly.

Typing Indicators

Multi-bot workflows need visible latency handling. If a bot is calling an LLM, users should know it is working.

The base class sends Telegram’s typing action while work is in progress:

protected async withTypingIndicator<T>(work: () => Promise<T>): Promise<T> {
  await this.sendTypingAction();

  const interval = setInterval(() => {
    void this.sendTypingAction();
  }, 4000);

  try {
    return await work();
  } finally {
    clearInterval(interval);
  }
}

This is especially important for bot-to-bot chains because users are watching a group conversation unfold.

BotFather And Group Setup

The project needs four bot tokens:

DEBATER_RED_TOKEN=
DEBATER_BLUE_TOKEN=
SOCRATIC_TOKEN=
JUDGE_TOKEN=

It also needs exact usernames:

DEBATER_RED_USERNAME=YourClubDebaterRedBot
DEBATER_BLUE_USERNAME=YourClubDebaterBlueBot
SOCRATIC_USERNAME=YourClubSocraticBot
JUDGE_USERNAME=YourClubJudgeBot

The most important setup checklist:

  1. Enable Bot-to-Bot Communication Mode for all four bots in BotFather if available.
  2. Add all four bots to the same Telegram group.
  3. Give Socratic admin rights, or disable Group Privacy Mode.
  4. Confirm bot-authored messages appear in routing logs.

When DEBUG_BOT_ROUTING=true, a healthy run shows lines like:

[YourClubDebaterBlueBot] received: message_id=288 from=@YourClubDebaterRedBot ...
[YourClubSocraticBot] received: message_id=289 from=@YourClubDebaterBlueBot ...
[YourClubJudgeBot] received: message_id=307 from=@YourClubDebaterBlueBot text="/verdict@YourClubJudgeBot"

That is the proof that Telegram is doing the bot-to-bot delivery.

What Developers Can Build From This

The same pattern applies far beyond debate bots.

You can fork this into:

  • an incident-response room where triage, diagnosis, and comms bots collaborate,
  • a customer-support workflow where intake, policy, and escalation bots hand off visibly,
  • a code-review panel with architecture, security, and testing bots,
  • a classroom simulation where different characters challenge a student’s answer,
  • an AI game where specialized bots play roles in the group,
  • a research workflow where agents critique each other’s sources.

The reusable pieces are:

  • one Telegram bot identity per role,
  • visible messages as routing events,
  • exact username mentions,
  • explicit bot commands like /verdict@TargetBot,
  • shared state for turn control,
  • loop guards for safety,
  • routing logs for setup diagnosis.

Production Notes

This repo is local-first and intentionally simple. For production, consider:

  • persistent state with SQLite or Postgres,
  • structured logs,
  • health checks,
  • process supervision,
  • per-group state if running in multiple Telegram groups,
  • webhook mode if deploying to a server,
  • distributed locks if running multiple replicas.

Keep one rule intact if your goal is to showcase Bot-to-Bot Communication: do not add invisible local handoffs between bots. Let Telegram deliver the bot-authored messages, and use your application state only to decide whether a bot should answer.

Telegram Bot-to-Bot Communication turns the group chat into an orchestration surface. This project demonstrates that with a debate club, but the underlying pattern is broader: visible, auditable, role-based multi-agent workflows inside Telegram.

That is the piece worth building on.

Docs: https://core.telegram.org/bots/features#bot-to-bot-communication

Code and more: https://www.dailybuild.xyz/project/138-telegram-debate-club

Firefox 151, önemli yeniliklerle geldi

Firefox 151

Firefox 151Firefox için yayımlanan yeni sürüm, hem mobil hem de masaüstü sürümlerine kullanıcıları memnun edecek önemli yenilikler ekledi. Tarayıcının ücretsiz VPN’ine konum seçme imkanı getiren Mozilla, bu konuda yaptığı açıklamada, “ABD, İngiltere, Fransa, Almanya ve Kanada’daki Firefox kullanıcıları bugünden itibaren VPN desteği sunulan tüm ülkeler arasından seçim yaparak internette gezinebilecek.” ifadelerini kullandı. Masaüstüne, açık gizli sekmeyi kapatıp tüm verileri temizleyen ve yeni gizli sekme açan alev simgeli bir buton konumlandıran şirket, yeni sekmelerde görünen “Firefox Home” sayfasının tasarımını da yeniledi. Mobil sürüme şimdiye kadar getirilen tüm yapay zeka özelliklerinin kapatılmasına imkan tanıyan bir buton ekleyen Mozilla, internet sitesindeki içerikleri özetleyen “sallayarak özetle” özelliğini de mobil sürüme taşıdı. “Artık birden fazla PDF dosyasını doğrudan tarayıcının PDF görüntüleyicisinde birleştirebilirsiniz.” diyen şirket, internet sitelerinin kullanıcıları takip etmesini engelleyen altyapının güçlendirildiğini de aktardı.

Firefox 151, önemli yeniliklerle geldi yazısı ilk olarak Teknoloji haberleri – LOG üzerinde yayınlandı.

Form Responses Are the Missing Trigger for AI Workflow Automation

Most AI workflow automation discussions start from the wrong place.

They start with a blank canvas.

Build an AI workflow that handles inbound leads.
Build an AI agent workflow for customer requests.
Build an automation that summarizes submissions and sends follow-up.

Those are reasonable goals, but they hide the hardest question:

What is the first reliable event?

For many teams, the answer is already sitting in front of them.

It is the form response.

A form response is not just a row in a spreadsheet. It is structured input from a real person, submitted at a specific time, against a known form, with fields the team intentionally asked for. That makes it one of the cleanest triggers for AI workflow automation.

I have been building FORMLOVA, a form operations product that works from ChatGPT, Claude, Cursor, and other MCP-compatible clients. One product lesson keeps repeating:

The form is not the workflow. The response is the event that starts the workflow.

This is also where the common “ChatGPT form builder” or “Claude form builder” story gets too small.

Creating a form from a prompt is useful. But the durable automation problem starts after someone submits it.

AI Form Creation Is Only the First Step

It is now easy to ask an AI model to draft a form.

Create a webinar signup form.
Create a contact form for enterprise inquiries.
Create a customer feedback survey.

ChatGPT can suggest fields, labels, helper text, and a confirmation message. Claude can do the same. If the AI client is connected to a real form service, it can go further and create an actual private draft form instead of just returning text.

That is a real improvement.

It removes the blank-page problem.

But it does not solve the operational problem.

After the form is published, the questions change:

  • Was the response recorded?
  • Did the respondent get the right acknowledgement?
  • Should this response be routed to sales, support, recruiting, or operations?
  • Is it spam, a sales pitch, a real inquiry, or a high-priority lead?
  • Who owns the next action?
  • Has anyone replied?
  • Should the response be synced to a CRM, spreadsheet, mailing list, calendar, or report?
  • What should happen if an email send fails?

That is the real AI workflow.

A prompt-to-form feature solves creation. A post-submit workflow solves operations.

The Best Trigger Is Structured Human Input

AI workflow tools often need to spend a lot of effort cleaning up messy input.

Emails are inconsistent.

Slack threads drift.

Meeting notes are long and ambiguous.

CRM notes are incomplete.

Support tickets vary by agent.

Form responses are different. They already have structure.

{
  "form_id": "enterprise_contact",
  "response_id": "resp_123",
  "submitted_at": "2026-05-20T09:15:00Z",
  "name": "Mina Sato",
  "company": "Northstar Labs",
  "inquiry_type": "partnership",
  "message": "We want to run event registrations with reminders and status tracking.",
  "consent": true
}

That structure matters.

The model does not have to guess what the fields mean from a paragraph of text. The workflow does not have to infer when the event happened. The system does not have to ask whether this came from a known collection point.

The response already carries the context the workflow needs.

That is why forms are a better starting point for many AI agent workflows than a generic “watch my inbox” instruction.

The form has schema.

The response has identity.

The submission has time.

The workflow can now make decisions.

A Useful AI Workflow Has Deterministic Rails

When people say “AI workflow automation,” they sometimes imagine the model doing everything.

That is usually a bad design.

For form response workflows, I prefer a split:

Layer Should be deterministic? Should AI help?
Save the response Yes No
Validate required fields Yes Sometimes
Send acknowledgement Yes AI can draft copy
Classify intent Partly Yes
Detect sales pitches or spam Partly Yes
Assign owner Usually AI can suggest
Update response status Yes AI can recommend
Draft follow-up No Yes
Send final external email Yes, with confirmation AI can prepare
Log the action Yes No

AI is strongest where the input is fuzzy:

  • classify intent
  • summarize long answers
  • detect urgency
  • extract next action
  • draft a reply
  • suggest routing
  • explain why a response is risky or high value

The workflow system still needs deterministic rails:

  • status values
  • owner fields
  • retry logic
  • idempotency keys
  • audit logs
  • permission checks
  • human approval for risky actions

The best version is not “let the agent improvise.”

The best version is:

Use AI for judgment.
Use workflow state for control.

A Response Workflow Is More Than a Notification

Many teams start by connecting a form to Slack.

That is useful.

It is also incomplete.

Form submitted -> Slack message posted

This is a notification, not a workflow.

A workflow needs state.

response.submitted
-> acknowledgement.sent
-> intent.classified
-> owner.assigned
-> status.in_progress
-> follow_up.drafted
-> reply.sent
-> status.done

That model lets you answer operational questions:

  • Which responses are still open?
  • Which ones were excluded from analysis?
  • Which leads were routed but never replied to?
  • Which auto-replies failed?
  • Which messages were drafted by AI but not approved?
  • Which forms produce the most manual follow-up?

If your automation cannot answer those questions, it is not really operating the form response. It is just reacting to it.

Where ChatGPT and Claude Fit

ChatGPT and Claude are not only useful before the form exists.

They are useful throughout the response lifecycle.

Before publishing:

Create a webinar signup form.
Add a required company field.
Rewrite the confirmation message in a warmer tone.
Check whether the form asks for too much information.

After publishing:

Show me new responses from this week.
Classify these inquiries by intent.
Exclude obvious sales pitches from the report.
Draft replies for the three partnership leads.
Create a reminder workflow for registrants who have not confirmed attendance.
Summarize conversion drop-off by traffic source.

This is the difference between an AI form builder and an AI form operations layer.

The first creates the asset.

The second operates the business process that starts from the asset.

That distinction is why MCP matters here.

MCP, or Model Context Protocol, lets an AI client connect to external tools and workflows through a structured interface. In this context, a Claude MCP or ChatGPT MCP connection should not only expose basic form CRUD.

Basic tools are useful:

create_form
edit_form
list_forms
get_responses

But the workflow tools are where the product meaning lives:

classify_response_intent
set_response_status
assign_response_owner
draft_follow_up_email
configure_auto_reply
schedule_reminder
exclude_sales_message
generate_response_report

Those are not just API wrappers. They are operations.

Example: Enterprise Contact Form

Imagine a simple contact form.

Fields:

  • name
  • email
  • company
  • inquiry type
  • message
  • consent

The old automation model might be:

Send every response to Slack.

The AI workflow automation model is different.

1. Save response.
2. Acknowledge receipt.
3. Classify intent.
4. Detect sales pitch or spam.
5. Route real inquiries by type.
6. Assign owner.
7. Draft suggested reply.
8. Require human approval before sending.
9. Update status.
10. Include the response in weekly reporting.

AI helps at steps 3, 4, and 7.

Workflow state controls steps 1, 2, 5, 6, 8, 9, and 10.

That is a much stronger design than asking an AI agent to “handle contact form messages” with no lifecycle.

Example: Webinar Registration

A webinar signup form has a different workflow.

The response is not only a lead. It is a participant record.

The workflow may need to:

  • send a confirmation email
  • add the respondent to an attendee list
  • detect duplicate registrations
  • send a reminder three days before the event
  • send a same-day reminder
  • mark attendance status
  • send a follow-up email
  • export a segment for sales
  • report registration source and attendance rate

Again, the AI should not own everything.

AI can draft emails, summarize free-text expectations, group respondents by intent, and identify high-value attendees.

The system still needs reliable state:

registered
confirmed
reminded
attended
no_show
followed_up

Without that state, the workflow becomes a pile of messages.

With that state, AI can assist the process without becoming the process.

What to Store Before You Automate

If you are designing AI workflow automation around form responses, store more than the answer values.

At minimum, I want these fields somewhere in the response or workflow layer:

type ResponseWorkflowState = {
  responseId: string;
  formId: string;
  submittedAt: string;
  source?: string;
  status: "new" | "in_progress" | "done" | "excluded";
  ownerId?: string;
  classification?: {
    intent: string;
    confidence: number;
    reason: string;
  };
  acknowledgement: {
    required: boolean;
    state: "pending" | "sent" | "failed" | "not_required";
  };
  notification: {
    state: "pending" | "sent" | "failed" | "not_required";
    channel?: "email" | "slack" | "webhook";
  };
  followUp: {
    draftId?: string;
    approvedAt?: string;
    sentAt?: string;
  };
};

You do not need this exact schema.

But you do need the separation.

The response value is not the same as the workflow state.

The AI classification is not the same as the human decision.

The draft reply is not the same as the sent reply.

The notification is not the same as ownership.

This separation is what makes the workflow observable and recoverable.

What Not to Automate First

The tempting move is to automate the most impressive part first.

Do not start there.

Do not start by letting an agent send external replies automatically.

Do not start by letting the model update CRM fields without review.

Do not start by routing every response through a giant prompt.

Start with the boring workflow:

Record -> acknowledge -> classify -> route -> assign -> review -> act -> log

Then add AI where it removes real ambiguity.

If classification is useful, add it.

If drafting saves time, add it.

If summarization helps weekly review, add it.

If an action affects a customer, a payment, a legal commitment, or a public message, add confirmation.

Production AI workflow automation is not about removing every human. It is about putting human attention on the decisions that need it.

Why This Is a Good Fit for FORMLOVA

This is the reason I do not think of FORMLOVA as only a form builder.

Yes, you can use ChatGPT or Claude to create a private draft form with FORMLOVA.

That entry point matters. If you are searching for a ChatGPT form builder or Claude form builder, you should be able to get from a short prompt to a real draft and preview.

But the bigger product surface is after that:

  • response search
  • response status
  • auto-replies
  • reminders
  • sales-message exclusion
  • analytics
  • reports
  • workflow recipes
  • MCP-based operations from AI clients

The form is the intake surface.

The response is the workflow trigger.

The operating layer is where the value compounds.

The Practical Takeaway

If you are building or evaluating AI workflow automation, do not only ask:

Can this AI create the form?

Ask:

What happens after the first real response arrives?

That is where the workflow becomes real.

Can the system classify the response?

Can it route ownership?

Can it keep status?

Can it draft without sending?

Can it require approval?

Can it retry side effects safely?

Can it explain what happened later?

The best trigger for an AI workflow is often not a blank prompt.

It is a structured response from a real person.

That is where the automation should start.

Disclosure

I am building FORMLOVA, so this article is written from the perspective of someone designing a form operations product. The product examples above are based on the operating model I want FORMLOVA to make normal: create the form from ChatGPT or Claude, then keep the response lifecycle visible instead of hiding it in disconnected notifications and spreadsheets.

Further Reading

Streamlining SaaS Quoting: Enhanced User Experience and Granular Control

Introduction

In the Reimpact platform project, our SaaS quote form is a critical component for potential clients to configure and estimate their service packages. A smooth, intuitive, and transparent quoting process is essential for user satisfaction and conversion. Recently, we focused on significant enhancements to this form to improve clarity, flexibility, and control over service selections and pricing.

Improving the User Experience

The previous iteration of our quote form, while functional, presented some areas for improvement in terms of user guidance and flexibility. Our recent updates addressed these directly:

Clearer Labels and Guidance

To enhance user understanding, we refined several key labels. For instance, “Servicios Extra (Horas)” was simplified to “Servicios Extra”, making it less prescriptive and more inviting. Similarly, module selection received clearer guidance with a new subtitle: “(Seleccionar al menos 1)”, ensuring users understand the minimum requirement.

Flexible Module Selection

A significant change involved the ‘Packaging’ module. Previously, it was designated as a mandatory base module, often creating friction for users who might not require it. We’ve now removed its ‘required’ status, making it, like all other modules, optional. This allows customers greater freedom to tailor their package precisely to their needs, without being forced into unnecessary selections.

Enhanced Quoting Logic with Service Limits

Beyond cosmetic and structural improvements, we introduced more robust logic for service quantity selection. To prevent over-selection or misconfiguration, we’ve implemented per-service maximum quantities on the slider inputs. This ensures that users select quantities within predefined business rules, providing both guardrails for the user and predictability for our operations.

For example, services like ‘Baseline’, ‘Audit’, ‘Support’, ‘Training’, and ‘Consulting’ now have specific upper limits (e.g., Baseline at 20, Audit at 12, Support at 24, Training at 1, Consulting at 24). This not only guides the user but also ensures the integrity of the generated quote.

<?php

class ServiceLimits
{
    public static function getLimits(): array
    {
        return [
            'baseline'   => ['min' => 1, 'max' => 20],
            'audit'      => ['min' => 0, 'max' => 12],
            'support'    => ['min' => 0, 'max' => 24],
            'training'   => ['min' => 0, 'max' => 1],
            'consulting' => ['min' => 0, 'max' => 24],
        ];
    }

    public static function validateQuantity(string $service, int $quantity): bool
    {
        $limits = self::getLimits();
        if (isset($limits[$service])) {
            return $quantity >= $limits[$service]['min'] && $quantity <= $limits[$service]['max'];
        }
        return false; // Service not found or no limits defined
    }
}

// Example usage in a form processing logic:
$serviceName = 'audit';
$userQuantity = 15; // User attempts to select 15

if (!ServiceLimits::validateQuantity($serviceName, $userQuantity)) {
    echo "Error: Invalid quantity for {$serviceName}."; // This would trigger an error
} else {
    echo "Quantity {$userQuantity} for {$serviceName} is valid.";
}

?>

This PHP example illustrates how these limits could be defined and validated within the application, ensuring that user inputs conform to the business rules.

Transparent Summary Details

One of the most impactful improvements for user confidence is the new itemized breakdown in the summary panel. Instead of a high-level total, users now see a clear, detailed list of each selected module and service, along with their respective quantities and costs. This transparency allows users to easily review their choices, understand how the total is calculated, and build trust in the quoting process.

Conclusion

These enhancements to the Reimpact platform’s SaaS quote form collectively deliver a more intuitive, flexible, and transparent experience. By improving labels, offering greater module choice, implementing robust service limits, and providing a detailed summary, we empower users to configure their ideal service package with confidence, leading to a better overall user journey and more accurate quotes.