Server-Side Rendering Strategies for Video Platforms

In the era of React and Next.js, I chose full server-side rendering with PHP for TopVideoHub. Here’s why, and how I implemented it for optimal performance.

Why SSR with PHP (Not a JS Framework)

For a content-heavy video platform, SSR with PHP has clear advantages:

  1. SEO — Search engines see fully rendered HTML immediately. No hydration delay, no JS-dependent content.
  2. Performance — Zero JavaScript framework overhead. The page is ready on first paint.
  3. Hosting cost — PHP shared hosting costs $3-10/month. Node.js hosting starts at $5-20/month for comparable performance.
  4. Simplicity — No build step, no bundler configuration, no client/server code splitting.

Template Architecture

I use plain PHP templates with a simple layout system:

// app/View.php
class View {
    private string $layout = 'main';
    private array $data = [];
    private array $sections = [];

    public function render(string $template, array $data = []): string {
        $this->data = $data;
        extract($data);

        ob_start();
        require __DIR__ . "/../templates/{$template}.php";
        $content = ob_get_clean();

        $this->sections['content'] = $content;

        ob_start();
        require __DIR__ . "/../templates/layouts/{$this->layout}.php";
        return ob_get_clean();
    }

    public function section(string $name): string {
        return $this->sections[$name] ?? '';
    }

    public function setLayout(string $layout): void {
        $this->layout = $layout;
    }
}
<!-- templates/layouts/main.php -->
<!DOCTYPE html>
<html lang="<?= $lang ?? 'en' ?>">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><?= htmlspecialchars($title ?? 'TopVideoHub') ?></title>
    <?= $this->section('head') ?>
    <link rel="stylesheet" href="/assets/style.css">
</head>
<body>
    <?php require __DIR__ . '/../partials/nav.php'; ?>
    <main>
        <?= $this->section('content') ?>
    </main>
    <?php require __DIR__ . '/../partials/footer.php'; ?>
    <?= $this->section('scripts') ?>
</body>
</html>

Video Grid Component

<!-- templates/partials/video-grid.php -->
<div class="video-grid" data-region="<?= $region ?>">
    <?php foreach ($videos as $video): ?>
    <article class="video-card" data-video-id="<?= $video['video_id'] ?>">
        <div class="video-facade">
            <img src="<?= thumbnailUrl($video['video_id'], 'mq') ?>"
                 alt="<?= htmlspecialchars($video['title']) ?>"
                 width="320" height="180"
                 loading="<?= $loop_index < 4 ? 'eager' : 'lazy' ?>"
                 decoding="async">
            <button class="play-btn" aria-label="Play <?= htmlspecialchars($video['title']) ?>">
                <svg viewBox="0 0 68 48"><!-- play icon SVG --></svg>
            </button>
            <span class="video-duration">
                <?= formatDuration($video['duration']) ?>
            </span>
        </div>
        <div class="video-info">
            <h3 class="video-title">
                <a href="/watch/<?= $video['video_id'] ?>">
                    <?= htmlspecialchars($video['title']) ?>
                </a>
            </h3>
            <p class="video-channel">
                <?= htmlspecialchars($video['channel_title']) ?>
            </p>
            <p class="video-meta">
                <span><?= formatViewCount($video['view_count']) ?> views</span>
                <span><?= timeAgo($video['published_at']) ?></span>
            </p>
        </div>
    </article>
    <?php endforeach; ?>
</div>

Progressive Enhancement

The page works without JavaScript. JS enhances the experience:

// Progressive enhancement: only runs if JS is available
(function() {
    // Lazy-load video embeds on click
    document.querySelectorAll('.video-facade').forEach(facade => {
        facade.addEventListener('click', function() {
            const id = this.closest('.video-card').dataset.videoId;
            const iframe = document.createElement('iframe');
            iframe.src = `https://www.youtube.com/embed/${id}?autoplay=1`;
            iframe.allow = 'autoplay; encrypted-media';
            iframe.allowFullscreen = true;
            this.innerHTML = '';
            this.appendChild(iframe);
        });
    });

    // Catbar responsive sizing
    const catbar = document.querySelector('.vw-catbar');
    if (catbar) {
        fitCatbar(catbar);
        window.addEventListener('resize', () => fitCatbar(catbar));
    }
})();

Cache-Aware Rendering

Since LiteSpeed caches full HTML responses, I ensure the rendered output is cache-friendly:

class CacheAwareRenderer {
    public function render(string $template, array $data, PageType $type): string {
        // Set cache headers before any output
        CachePolicy::apply($type, ...($data['cache_tags'] ?? []));

        // Render template
        $view = new View();
        $html = $view->render($template, $data);

        // Also write to PHP file cache as fallback
        $cacheKey = md5($_SERVER['REQUEST_URI']);
        $cachePath = __DIR__ . "/../data/pagecache/{$cacheKey}.html";
        file_put_contents($cachePath, $html, LOCK_EX);

        return $html;
    }
}

Results at TopVideoHub

This SSR approach on TopVideoHub delivers:

  • TTFB: ~12ms (cached), ~120ms (uncached)
  • FCP: ~0.8s
  • Total JS: 8KB (minified)
  • Lighthouse Performance: 85+ mobile, 95+ desktop

No build step, no hydration, no JavaScript framework. Just PHP rendering HTML and LiteSpeed caching it. For content platforms, this approach is hard to beat.

How We’re Solving Context Window Bloat in an AI Agent Skill Ecosystem

Your AI agent just got its 53rd skill installed. Image generation, video creation, social media posting, calendar management — the works.

There’s just one problem: every single request now carries 25KB of skill descriptions in the system prompt, whether the user needs them or not. That’s ~6,200 tokens of overhead before a single word of actual conversation.

This post walks through how we found this problem, the four approaches we tried (and why three of them failed), and the architecture we landed on.

The Problem: More Skills = Worse Performance

We run an AI agent platform where users install “skills” — essentially instruction modules that tell the agent how to use specific tools. Think of them like plugins, but implemented as structured markdown files that get injected into the system prompt.

The mechanism is simple:

Install skill → SKILL.md stored locally  
→ name + description injected into every request's system prefix  
→ Agent sees full skill list → matches → reads SKILL.md → executes

When we audited our system prefix, here’s what we found:

Component Size Share
Tool schemas 29.6 KB 31.2%
User workspace files 30.8 KB 32.5%
Skills list 24.9 KB 26.2%
Framework instructions 9.5 KB 10.0%
Total 92.5 KB —

The skills list was eating over a quarter of our context budget. And usage data showed 45% of installed skills had never been triggered — they were just dead weight on every request.

At 53 skills this was annoying but survivable. At 500? The system would collapse.

The Core Tension

The business needs breadth — the more skills available, the more capable the agent. But the runtime needs precision — each request should only carry what’s relevant.

We also had a hard constraint: LLM prefix caching. The cache matches tokens from the start of the sequence. If you change anything in the system prefix, everything after that point becomes a cache miss. Skills sit near the front of the prefix, before all conversation history. Touching them means rewriting the cache for the entire conversation — exactly the opposite of what we want.

Approach 1: Two-Layer Architecture (Pinned + Dynamic Discovery)

Idea: Split skills into a “pinned” tier (10-15 high-frequency ones, always injected) and an “ecosystem” tier (hundreds, discovered on demand). Add a new built-in tool for skill discovery.

Why it failed: This required modifying the agent framework’s source code — its configuration format, adding a new built-in tool, changing the prompt assembly pipeline.

The framework we use ships updates almost every other day. Maintaining a fork against that velocity is a maintenance nightmare. Even with zero feature work on our end, we’d be constantly rebasing against upstream changes.

Decision: No approach that requires forking or modifying the core framework.

Approach 2: Use a Skill to Manage Skills

Idea: Completely non-invasive. Move low-frequency skills out of the scan directory (so they’re not injected), and create a “skill-router” skill that searches through archived skills when needed.

High-frequency skills → standard directory (injected)
Low-frequency skills → archive directory (not injected)
skill-router → searches archive via grep when agent can't handle a request

This was elegant — zero code changes, just filesystem operations plus one regular skill.

Why it failed: We tracked trigger reliability across our production data and found:

  • Skill trigger rate based on description matching alone: < 30%
  • With cross-references from other skills: 70-80%
  • Even our best-documented knowledge-base skill (strong description + referenced by multiple other skills) was missed ~25% of the time

The root cause: agents are probabilistic. Building a critical path on “the agent realizes it needs to search for help” has a reliability ceiling that’s too low for production.

Decision: Critical routing can’t depend on the agent’s probabilistic judgment.

Approach 3: Dynamic Injection via Plugin Hook

Idea: Use the framework’s plugin system (specifically a context assembly hook) to dynamically choose which skills to inject based on the user’s message. Instead of a static skill list, compute the relevant subset each time.

This felt right — deterministic code picks the skills, not the agent’s judgment.

Why it failed: Remember the cache constraint? The skills list sits in the system prefix, before all conversation history. Dynamically changing it means the prefix is different on every request, which cascades into a full cache miss for all historical messages.

We ran the numbers: saving 24.9 KB of skill space but causing 50-100 KB of cache rewrites on every turn. Net negative.

Decision: The system prefix must remain 100% stable. No dynamic modifications to anything before the conversation history.

Approach 4: Append to End (The Solution) ✅

The breakthrough was reframing the problem. Instead of replacing part of the prefix, we append to the end of the message sequence — after all conversation history.

[Fixed prefix: tools + pinned skills + user files + instructions]  → NEVER CHANGES (cache hit)
[Conversation history]                                              → cache hit  
[Additional Skills: dynamically matched this turn]                  → small new addition

Here’s why this works:

  1. Prefix stays 100% stable — full cache hit on every turn
  2. Dynamic content is append-only — minimal cache write cost
  3. Deterministic matching — code picks the skills, not the agent
  4. Scales indefinitely — ecosystem can have thousands of skills, but each request only carries 2-3 relevant ones

The Matching Layer

We use embedding similarity to match the user’s message against pre-computed skill description vectors:

// In the assembly hook
const response = await openai.embeddings.create({
  model: "text-embedding-3-small",
  input: userMessage.text,
});

const queryVector = response.data[0].embedding;
const matchedSkills = cosineSimilaritySearch(queryVector, skillIndex, topK);

The skill index is pre-computed at install time:

[
  {
    "name": "xhs-note-creator",
    "description": "Create Xiaohongshu note content...",
    "location": "~/.agent/skills-archive/xhs-note-creator/SKILL.md",
    "embedding": [0.012, -0.034, 0.056, ...]
  }
]
  • Index size: 500 skills × 1536-dim float32 ≈ 3 MB (totally manageable)
  • Matching latency: ~100-200ms per request
  • Cost: ~$0.02 per million tokens (negligible)

Why Append Works for the Agent

If you’ve done RAG, this pattern is familiar. The agent sees:

“Here are some additional skills that may be relevant to this request: [skill descriptions]”

It then reads the corresponding SKILL.md files and executes normally. From the agent’s perspective, it’s just extra context — no behavioral changes needed.

What We Got Wrong Along the Way

A few pitfalls worth noting:

  1. We overestimated agent self-awareness. We assumed the agent would reliably recognize “I don’t know how to do this, let me search for a skill.” In practice, it either hallucinated an answer or just apologized — searching was the last resort, not the first.

  2. We underestimated cache sensitivity. Our initial mental model was “save tokens in the prefix → save money.” But prefix caching means the stability of the prefix matters more than its size. A 90 KB stable prefix is cheaper than a 70 KB prefix that changes every turn.

  3. We almost built a fork. The two-layer architecture was technically clean, but maintaining a fork of a fast-moving open source project is a long-term tax that compounds. Using the official plugin system — even if it’s less flexible — was the right call.

Rollout Plan

We’re being deliberate about timing:

  • Now (< 60 skills): No changes needed. The overhead is acceptable and we’re collecting usage data.
  • 100+ skills: Deploy the routing extension. Move low-frequency skills to archive. Validate matching accuracy.
  • 500+ skills: Automate index management. Add user-profile-based pinning. Connect to the skill registry for remote discovery.

Key Takeaways

  1. Injection cost is the hidden tax of plugin ecosystems. Every plugin/skill/tool added to an AI agent’s context has a per-request cost, even when unused.

  2. Cache-friendliness is a first-class architectural constraint. For LLM-based systems, prefix stability matters more than prefix size.

  3. Don’t build critical paths on probabilistic behavior. If your system relies on the agent “deciding” to do the right thing, measure the actual trigger rate before shipping.

  4. Append > Replace for dynamic context. When you need to add context without breaking caches, treat it like RAG — add to the end, not the middle.

  5. Resist the fork. Plugin/extension systems exist for a reason. The flexibility tax of a fork almost always exceeds the flexibility gain.

This architecture now powers part of how we think about skill scaling at www.imaclaw.ai, where we build AI creative agents with 50+ multimodal skills. The pattern should generalize to any LLM agent system dealing with growing plugin ecosystems.

Claude Codeでヘルスエンドポイントを設計する:依存サービス確認・Liveness/Readiness・Kubernetes対応

はじめに

「ヘルスチェックが200 OKを返しているのにDBに繋がらない」「Kubernetesがポッドを再起動しすぎる」——適切なLiveness・Readiness・Startupプローブとヘルスエンドポイントを設計し、真に「ヘルシー」な状態だけを報告する設計をClaude Codeに生成させる。

CLAUDE.mdにヘルスエンドポイント設計ルールを書く

## ヘルスエンドポイント設計ルール

### エンドポイント分離
- /health/live: Liveness(プロセスが生きているか)→ DBチェック不要
- /health/ready: Readiness(リクエストを処理できるか)→ 全依存サービスをチェック
- /health: 管理者向け詳細ステータス(認証必須)

### 依存サービスのチェック
- PostgreSQL: SELECT 1でクエリ確認
- Redis: PINGで確認
- 外部API: 軽量なAPIを叩く(ヘルスエンドポイントがあれば使う)
- タイムアウト: 各チェック最大2秒

### キャッシュ
- Readinessチェックの結果を5秒キャッシュ(連続チェックでDBを叩かない)
- Livenessはキャッシュしない(常に最新状態を報告)

ヘルスエンドポイント実装の生成

ヘルスエンドポイントを設計してください。

要件:
- Liveness/Readiness/Startupの分離
- 依存サービスの並列チェック
- タイムアウト制御
- Kubernetes対応

生成ファイル: src/health/

生成されるヘルスエンドポイント実装

// src/health/healthChecker.ts — ヘルスチェック

export type CheckStatus = 'ok' | 'degraded' | 'down';

export interface CheckResult {
  name: string;
  status: CheckStatus;
  durationMs: number;
  message?: string;
}

export interface HealthReport {
  status: CheckStatus;
  checks: CheckResult[];
  version: string;
  uptime: number;   // プロセス起動からの秒数
  timestamp: string;
}

export type HealthChecker = () => Promise<CheckResult>;

export class HealthCheckService {
  private readonly checks: Map<string, HealthChecker> = new Map();
  private readonly cache = new Map<string, { result: CheckResult; expiresAt: number }>();

  register(name: string, checker: HealthChecker, cacheTtlMs = 5_000): void {
    this.checks.set(name, async () => {
      // キャッシュチェック
      const cached = this.cache.get(name);
      if (cached && Date.now() < cached.expiresAt) return cached.result;

      const result = await checker();
      this.cache.set(name, { result, expiresAt: Date.now() + cacheTtlMs });
      return result;
    });
  }

  async runAll(): Promise<HealthReport> {
    const checkEntries = [...this.checks.entries()];

    const results = await Promise.allSettled(
      checkEntries.map(([, checker]) => checker())
    );

    const checks: CheckResult[] = results.map((r, i) => {
      if (r.status === 'fulfilled') return r.value;
      return {
        name: checkEntries[i][0],
        status: 'down' as const,
        durationMs: 0,
        message: (r.reason as Error).message,
      };
    });

    const overallStatus: CheckStatus =
      checks.some(c => c.status === 'down') ? 'down' :
      checks.some(c => c.status === 'degraded') ? 'degraded' : 'ok';

    return {
      status: overallStatus,
      checks,
      version: process.env.APP_VERSION ?? 'unknown',
      uptime: process.uptime(),
      timestamp: new Date().toISOString(),
    };
  }

  async runReadiness(): Promise<HealthReport> {
    return this.runAll();
  }

  // Livenessは軽量チェックのみ(メモリ使用量など)
  async runLiveness(): Promise<{ status: 'ok' | 'down'; memoryMb: number }> {
    const memMb = process.memoryUsage().heapUsed / 1024 / 1024;
    const memLimit = parseInt(process.env.MEMORY_LIMIT_MB ?? '512');

    return {
      status: memMb < memLimit * 0.9 ? 'ok' : 'down',
      memoryMb: Math.round(memMb),
    };
  }
}

// 各依存サービスのチェッカー
export function createPostgresChecker(db: PrismaClient): HealthChecker {
  return async () => {
    const start = Date.now();
    try {
      await Promise.race([
        db.$queryRaw`SELECT 1`,
        new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000)),
      ]);
      return { name: 'postgresql', status: 'ok', durationMs: Date.now() - start };
    } catch (error) {
      return { name: 'postgresql', status: 'down', durationMs: Date.now() - start, message: (error as Error).message };
    }
  };
}

export function createRedisChecker(client: RedisClient): HealthChecker {
  return async () => {
    const start = Date.now();
    try {
      await Promise.race([
        client.ping(),
        new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000)),
      ]);
      return { name: 'redis', status: 'ok', durationMs: Date.now() - start };
    } catch (error) {
      return { name: 'redis', status: 'down', durationMs: Date.now() - start, message: (error as Error).message };
    }
  };
}

export function createExternalApiChecker(name: string, url: string): HealthChecker {
  return async () => {
    const start = Date.now();
    try {
      const response = await fetch(url, { signal: AbortSignal.timeout(2000) });
      const status: CheckStatus = response.ok ? 'ok' : response.status < 500 ? 'degraded' : 'down';
      return { name, status, durationMs: Date.now() - start };
    } catch (error) {
      return { name, status: 'down', durationMs: Date.now() - start, message: (error as Error).message };
    }
  };
}
// src/health/healthRouter.ts — ヘルスルーター(Kubernetes対応)

const healthService = new HealthCheckService();

// 依存サービスを登録(5秒キャッシュ)
healthService.register('postgresql', createPostgresChecker(prisma), 5_000);
healthService.register('redis', createRedisChecker(redis), 5_000);
healthService.register('payment-api', createExternalApiChecker('payment-api', process.env.PAYMENT_API_HEALTH_URL!), 10_000);

// Kubernetes Liveness Probe: プロセスが生きているかだけ
// 失敗すると: Pod再起動
router.get('/health/live', async (req, res) => {
  const result = await healthService.runLiveness();

  if (result.status === 'ok') {
    res.status(200).json(result);
  } else {
    // メモリ使用量が90%超 → 再起動させる
    res.status(503).json(result);
  }
});

// Kubernetes Readiness Probe: リクエストを受け付けられるか
// 失敗すると: Service から除外(トラフィックを送らない)
router.get('/health/ready', async (req, res) => {
  const report = await healthService.runReadiness();

  if (report.status === 'ok') {
    res.status(200).json(report);
  } else if (report.status === 'degraded') {
    // 一部の依存が不安定: 200を返してトラフィックは受け続ける
    // (全部落とすと困る場合)
    res.status(200).json(report);
  } else {
    // 必須依存が落ちている: 503でService から除外
    res.status(503).json(report);
  }
});

// 管理者向け詳細ヘルス(認証必須)
router.get('/health', requireAdmin, async (req, res) => {
  const report = await healthService.runAll();
  const statusCode = report.status === 'ok' ? 200 : report.status === 'degraded' ? 200 : 503;
  res.status(statusCode).json(report);
});

// Kubernetes設定例
/*
livenessProbe:
  httpGet:
    path: /health/live
    port: 3000
  initialDelaySeconds: 10
  periodSeconds: 10
  failureThreshold: 3

readinessProbe:
  httpGet:
    path: /health/ready
    port: 3000
  initialDelaySeconds: 5
  periodSeconds: 5
  failureThreshold: 3
*/

まとめ

Claude Codeでヘルスエンドポイントを設計する:

  1. CLAUDE.md にLiveness(プロセス生死のみ)/Readiness(全依存確認)を分離・各チェック2秒タイムアウト・結果を5秒キャッシュしてDBスパイク防止を明記
  2. Liveness/Readiness分離 でKubernetesの再起動判断を適切に制御——Readinessがf失敗してもPodを再起動せず(traffic-outのみ)、Livenessが失敗したら初めてPodを再起動
  3. 並列チェック でPostgreSQL+Redis+外部APIを同時確認——逐次だと6秒かかるチェックが2秒で完了。AllSettledで1つが遅くても他の結果を捨てない
  4. degraded状態 で部分的な問題を表現——Readiness=200(degraded)で「一部不安定だがトラフィックは受け付ける」と「全部落とす」の中間の対応が可能

信頼性設計のレビューは **Code Review Pack(¥980)* の /code-review で確認できます。*

prompt-works.jp

みょうが (@myougatheaxo) — ウーパールーパーのVTuber。

Claude Codeでヘルスエンドポイントを設計する:依存サービス確認・Liveness/Readiness・Kubernetes対応

はじめに

「ヘルスチェックが200 OKを返しているのにDBに繋がらない」「Kubernetesがポッドを再起動しすぎる」——適切なLiveness・Readiness・Startupプローブとヘルスエンドポイントを設計し、真に「ヘルシー」な状態だけを報告する設計をClaude Codeに生成させる。

CLAUDE.mdにヘルスエンドポイント設計ルールを書く

## ヘルスエンドポイント設計ルール

### エンドポイント分離
- /health/live: Liveness(プロセスが生きているか)→ DBチェック不要
- /health/ready: Readiness(リクエストを処理できるか)→ 全依存サービスをチェック
- /health: 管理者向け詳細ステータス(認証必須)

### 依存サービスのチェック
- PostgreSQL: SELECT 1でクエリ確認
- Redis: PINGで確認
- 外部API: 軽量なAPIを叩く(ヘルスエンドポイントがあれば使う)
- タイムアウト: 各チェック最大2秒

### キャッシュ
- Readinessチェックの結果を5秒キャッシュ(連続チェックでDBを叩かない)
- Livenessはキャッシュしない(常に最新状態を報告)

ヘルスエンドポイント実装の生成

ヘルスエンドポイントを設計してください。

要件:
- Liveness/Readiness/Startupの分離
- 依存サービスの並列チェック
- タイムアウト制御
- Kubernetes対応

生成ファイル: src/health/

生成されるヘルスエンドポイント実装

// src/health/healthChecker.ts — ヘルスチェック

export type CheckStatus = 'ok' | 'degraded' | 'down';

export interface CheckResult {
  name: string;
  status: CheckStatus;
  durationMs: number;
  message?: string;
}

export interface HealthReport {
  status: CheckStatus;
  checks: CheckResult[];
  version: string;
  uptime: number;   // プロセス起動からの秒数
  timestamp: string;
}

export type HealthChecker = () => Promise<CheckResult>;

export class HealthCheckService {
  private readonly checks: Map<string, HealthChecker> = new Map();
  private readonly cache = new Map<string, { result: CheckResult; expiresAt: number }>();

  register(name: string, checker: HealthChecker, cacheTtlMs = 5_000): void {
    this.checks.set(name, async () => {
      // キャッシュチェック
      const cached = this.cache.get(name);
      if (cached && Date.now() < cached.expiresAt) return cached.result;

      const result = await checker();
      this.cache.set(name, { result, expiresAt: Date.now() + cacheTtlMs });
      return result;
    });
  }

  async runAll(): Promise<HealthReport> {
    const checkEntries = [...this.checks.entries()];

    const results = await Promise.allSettled(
      checkEntries.map(([, checker]) => checker())
    );

    const checks: CheckResult[] = results.map((r, i) => {
      if (r.status === 'fulfilled') return r.value;
      return {
        name: checkEntries[i][0],
        status: 'down' as const,
        durationMs: 0,
        message: (r.reason as Error).message,
      };
    });

    const overallStatus: CheckStatus =
      checks.some(c => c.status === 'down') ? 'down' :
      checks.some(c => c.status === 'degraded') ? 'degraded' : 'ok';

    return {
      status: overallStatus,
      checks,
      version: process.env.APP_VERSION ?? 'unknown',
      uptime: process.uptime(),
      timestamp: new Date().toISOString(),
    };
  }

  async runReadiness(): Promise<HealthReport> {
    return this.runAll();
  }

  // Livenessは軽量チェックのみ(メモリ使用量など)
  async runLiveness(): Promise<{ status: 'ok' | 'down'; memoryMb: number }> {
    const memMb = process.memoryUsage().heapUsed / 1024 / 1024;
    const memLimit = parseInt(process.env.MEMORY_LIMIT_MB ?? '512');

    return {
      status: memMb < memLimit * 0.9 ? 'ok' : 'down',
      memoryMb: Math.round(memMb),
    };
  }
}

// 各依存サービスのチェッカー
export function createPostgresChecker(db: PrismaClient): HealthChecker {
  return async () => {
    const start = Date.now();
    try {
      await Promise.race([
        db.$queryRaw`SELECT 1`,
        new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000)),
      ]);
      return { name: 'postgresql', status: 'ok', durationMs: Date.now() - start };
    } catch (error) {
      return { name: 'postgresql', status: 'down', durationMs: Date.now() - start, message: (error as Error).message };
    }
  };
}

export function createRedisChecker(client: RedisClient): HealthChecker {
  return async () => {
    const start = Date.now();
    try {
      await Promise.race([
        client.ping(),
        new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000)),
      ]);
      return { name: 'redis', status: 'ok', durationMs: Date.now() - start };
    } catch (error) {
      return { name: 'redis', status: 'down', durationMs: Date.now() - start, message: (error as Error).message };
    }
  };
}

export function createExternalApiChecker(name: string, url: string): HealthChecker {
  return async () => {
    const start = Date.now();
    try {
      const response = await fetch(url, { signal: AbortSignal.timeout(2000) });
      const status: CheckStatus = response.ok ? 'ok' : response.status < 500 ? 'degraded' : 'down';
      return { name, status, durationMs: Date.now() - start };
    } catch (error) {
      return { name, status: 'down', durationMs: Date.now() - start, message: (error as Error).message };
    }
  };
}
// src/health/healthRouter.ts — ヘルスルーター(Kubernetes対応)

const healthService = new HealthCheckService();

// 依存サービスを登録(5秒キャッシュ)
healthService.register('postgresql', createPostgresChecker(prisma), 5_000);
healthService.register('redis', createRedisChecker(redis), 5_000);
healthService.register('payment-api', createExternalApiChecker('payment-api', process.env.PAYMENT_API_HEALTH_URL!), 10_000);

// Kubernetes Liveness Probe: プロセスが生きているかだけ
// 失敗すると: Pod再起動
router.get('/health/live', async (req, res) => {
  const result = await healthService.runLiveness();

  if (result.status === 'ok') {
    res.status(200).json(result);
  } else {
    // メモリ使用量が90%超 → 再起動させる
    res.status(503).json(result);
  }
});

// Kubernetes Readiness Probe: リクエストを受け付けられるか
// 失敗すると: Service から除外(トラフィックを送らない)
router.get('/health/ready', async (req, res) => {
  const report = await healthService.runReadiness();

  if (report.status === 'ok') {
    res.status(200).json(report);
  } else if (report.status === 'degraded') {
    // 一部の依存が不安定: 200を返してトラフィックは受け続ける
    // (全部落とすと困る場合)
    res.status(200).json(report);
  } else {
    // 必須依存が落ちている: 503でService から除外
    res.status(503).json(report);
  }
});

// 管理者向け詳細ヘルス(認証必須)
router.get('/health', requireAdmin, async (req, res) => {
  const report = await healthService.runAll();
  const statusCode = report.status === 'ok' ? 200 : report.status === 'degraded' ? 200 : 503;
  res.status(statusCode).json(report);
});

// Kubernetes設定例
/*
livenessProbe:
  httpGet:
    path: /health/live
    port: 3000
  initialDelaySeconds: 10
  periodSeconds: 10
  failureThreshold: 3

readinessProbe:
  httpGet:
    path: /health/ready
    port: 3000
  initialDelaySeconds: 5
  periodSeconds: 5
  failureThreshold: 3
*/

まとめ

Claude Codeでヘルスエンドポイントを設計する:

  1. CLAUDE.md にLiveness(プロセス生死のみ)/Readiness(全依存確認)を分離・各チェック2秒タイムアウト・結果を5秒キャッシュしてDBスパイク防止を明記
  2. Liveness/Readiness分離 でKubernetesの再起動判断を適切に制御——Readinessがf失敗してもPodを再起動せず(traffic-outのみ)、Livenessが失敗したら初めてPodを再起動
  3. 並列チェック でPostgreSQL+Redis+外部APIを同時確認——逐次だと6秒かかるチェックが2秒で完了。AllSettledで1つが遅くても他の結果を捨てない
  4. degraded状態 で部分的な問題を表現——Readiness=200(degraded)で「一部不安定だがトラフィックは受け付ける」と「全部落とす」の中間の対応が可能

信頼性設計のレビューは **Code Review Pack(¥980)* の /code-review で確認できます。*

prompt-works.jp

みょうが (@myougatheaxo) — ウーパールーパーのVTuber。

Backup and Restore Kubernetes Resources Across vCluster using Velero

In Kubernetes environments, teams are constantly looking for ways to move faster without sacrificing security or efficiency. Managing multiple environments like development, testing, and staging often leads to cluster sprawl, higher costs, and complex maintenance. This is where virtual clusters come in.

Virtual clusters make it possible to create isolated, on-demand Kubernetes environments that share the same underlying infrastructure. They give developers the freedom to spin up their own clusters quickly for testing new features, running experiments, or deploying temporary workloads — all without waiting on cluster admins or consuming extra resources. Each virtual cluster runs its own control plane, offering stronger isolation and flexibility than namespace-based setups. We’ll be using vCluster, an implementation of virtual clusters by Loft, to illustrate the concept in practice.

Managing workloads across multiple virtual clusters is a common pattern in multi-tenant environments. However, while virtual clusters make isolation easy, moving workloads across them is not straightforward. That’s where Velero comes in — it is a powerful Kubernetes backup tool that migrates workloads from one virtual cluster to another.

In this blog post, we’ll understand the importance of backups, how Velero works, and walk you through a practical migration of resources using Velero — from backing up one virtual cluster to restoring it in another.

What is Velero?

Velero is an open source tool to back up and restore your Kubernetes cluster resources and persistent volumes. You can run Velero with a cloud provider or on-premises.

Velero lets you:

  • Take backups of your cluster and restore in case of loss
  • Migrate cluster resources to other clusters
  • Replicate your production cluster to development and testing clusters

Velero consists of:

  1. Velero CLI

    • Runs on your local machine.
    • Used to create, schedule, and manage backups and restores.
  2. Kubernetes API Server

    • Receives backup requests from the Velero CLI.
    • Stores Velero custom resources (like Backup) in etcd.
  3. Velero Server (BackupController)

    • Runs inside the Kubernetes cluster.
    • Watches the Kubernetes API for Velero backup requests.
    • Collects Kubernetes resource data and triggers backups.
  4. Cloud Provider / Object Storage

    • Stores backup data and metadata.
    • Creates volume snapshots using the cloud provider’s API (e.g., Azure Disk Snapshots).

How it works:

  1. User runs a Velero backup command using the CLI: velero backup create my-backup
  2. CLI creates a backup request in Kubernetes
  3. The Velero server detects the request and gathers cluster resources
  4. Backup data is uploaded to cloud object storage
  5. Persistent volumes are backed up using cloud snapshots (if enabled)

Velero supports a variety of storage providers for different backup and snapshot operations. In this blog post, we will focus on the Azure provider.

What is vCluster?

vCluster enables building virtual clusters — a certified Kubernetes distribution that runs as isolated, virtual environments within a physical host cluster. They enhance isolation and flexibility in multi-tenant Kubernetes setups. Multiple teams can work independently on shared infrastructure, helping minimize conflicts, increase team autonomy, and reduce infrastructure costs.

A virtual cluster:

  • Runs inside a namespace of the host cluster
  • Has an API server, control plane, and syncer
  • Maintains its own set of Kubernetes resources, operating like a full cluster

Why Backup and Migrate Workloads Using vCluster?

Common reasons to back up or migrate workloads between vClusters:

  • Promoting apps from dev to staging or prod: Backing up and restoring workloads between vClusters allows smooth promotion of applications across environments, ensuring consistent configurations and deployments without manual rework.
  • Replicating test environments: It helps recreate identical test setups quickly, enabling developers to reproduce issues, validate fixes, or test new features in isolated environments.
  • Disaster recovery (DR) setup: Regular backups across vClusters ensure business continuity by allowing workloads to be restored rapidly in another cluster if the primary one fails.
  • Tenant migration in multi-tenant environments: vClusters make it easier to move tenants between isolated environments without affecting others, maintaining data security and minimizing downtime.
  • Cluster version upgrades or deprecations: When upgrading or decommissioning a cluster, backing up workloads to another vCluster ensures a seamless transition without losing data or configurations.

Why Use Velero with vCluster?

Virtual clusters built with vCluster are lightweight and isolated, but they don’t provide built-in mechanisms for backing up workloads, restoring them, or moving applications between clusters. Without a backup solution, recovery and migration can be risky.

Using Velero with vCluster fills this gap by enabling simple backup, restore, and migration workflows directly inside virtual clusters. It allows you to move applications between clusters with minimal setup and perform migrations with little to no downtime, especially for stateless workloads.

How to Backup and Migrate Workloads Between vClusters

Let’s see how to use Velero to back up workloads from one vCluster and restore them into another. Think of it as moving your app from dev to staging across two clusters running on two different Azure clusters.

Prerequisites

Before starting, make sure you have the following:

  • Two clusters up and running on Azure (any cloud offering works)
  • Two running vClusters (source and destination)
  • Velero CLI installed on your machine

Step-by-step Guide

In the source vCluster and destination vCluster, we will install Velero with the same configuration, deploy a sample MySQL Pod, take its backup at source, and restore it in the destination vCluster. We will be using the Azure provider to run Velero.

To set up Velero on Azure, you have to:

  • Create an Azure storage account and blob container
  • Get the resource group details
  • Set permissions for Velero

Velero needs access to your Azure storage account to upload and retrieve backups. You’ll need to assign the “Storage Blob Data Contributor” role (or equivalent) to the identity or service principal Velero uses, ensuring it can read, write, and manage backup data in the blob container.

1. Create Azure Resources

Create a resource group:

AZURE_RESOURCE_GROUP=<YOUR_RESOURCE_GROUP>
az group create --name $AZURE_RESOURCE_GROUP --location <YOUR_LOCATION>

Create the storage account:

AZURE_STORAGE_ACCOUNT=<YOUR_STORAGE_ACCOUNT>
az storage account create 
  --name $AZURE_STORAGE_ACCOUNT 
  --resource-group $AZURE_RESOURCE_GROUP 
  --sku Standard_GRS 
  --encryption-services blob 
  --https-only true 
  --kind BlobStorage 
  --access-tier Hot

Create a blob container:

BLOB_CONTAINER=velero
az storage container create 
  --name $BLOB_CONTAINER 
  --public-access off 
  --account-name $AZURE_STORAGE_ACCOUNT

2. Create a Service Principal with Contributor Privileges

AZURE_SUBSCRIPTION_ID=$(az account list --query '[?isDefault].id' -o tsv)
AZURE_TENANT_ID=$(az account list --query '[?isDefault].tenantId' -o tsv)

az ad sp create-for-rbac 
  --name "velero" 
  --role "Contributor" 
  --scopes /subscriptions/$AZURE_SUBSCRIPTION_ID 
  --query '{clientId: appId, clientSecret: password, tenantId: tenant}'

This outputs clientId, clientSecret, subscriptionId, and tenantId. Store these values.

Get the Client ID and store it in a variable:

AZURE_CLIENT_ID=$(az ad sp list --display-name "velero" --query '[0].appId' -o tsv)

Assign additional permissions to the Client ID:

az role assignment create 
  --assignee $AZURE_CLIENT_ID 
  --role "Storage Blob Data Contributor" 
  --scope /subscriptions/$AZURE_SUBSCRIPTION_ID/resourceGroups/$AZURE_RESOURCE_GROUP/providers/Microsoft.Storage/storageAccounts/$AZURE_STORAGE_ACCOUNT

3. Prepare Credentials

With the output received above, create bsl-creds and cloud-creds for the Velero setup.

  • BSL (Backup Storage Location) — the blob container where Velero stores backups. Velero needs a secret to access this storage location.
  • cloud-creds — credentials required to access the Azure cluster.

You will need the following values:

AZURE_SUBSCRIPTION_ID=<YOUR_SUBSCRIPTION_ID>
AZURE_TENANT_ID=<YOUR_TENANT_ID>
AZURE_CLIENT_ID=<YOUR_CLIENT_ID>
AZURE_CLIENT_SECRET=<YOUR_CLIENT_SECRET>
AZURE_RESOURCE_GROUP=<YOUR_RESOURCE_GROUP>
AZURE_CLOUD_NAME=AzurePublicCloud
AZURE_ENVIRONMENT=AzurePublicCloud

4. Log in to vCluster and Create Velero Namespace

kubectl create namespace velero

5. Create BSL and Cloud Credentials

bsl-creds.yaml:

apiVersion: v1
kind: Secret
metadata:
  name: bsl-creds
  namespace: velero
type: Opaque
data:
  cloud: <BASE64_ENCODED_VALUE>
  # Encode the following as base64:
  # [default]
  # storageAccount: <YOUR_STORAGE_ACCOUNT>
  # storageAccountKey: <YOUR_STORAGE_ACCOUNT_KEY>
  # subscriptionId: <YOUR_SUBSCRIPTION_ID>
  # resourceGroup: <YOUR_RESOURCE_GROUP>

cloud-creds.yaml:

apiVersion: v1
kind: Secret
metadata:
  name: cloud-creds
  namespace: velero
type: Opaque
data:
  cloud: <BASE64_ENCODED_VALUE>
  # Encode the following as base64:
  # AZURE_SUBSCRIPTION_ID=<YOUR_SUBSCRIPTION_ID>
  # AZURE_TENANT_ID=<YOUR_TENANT_ID>
  # AZURE_CLIENT_ID=<YOUR_CLIENT_ID>
  # AZURE_CLIENT_SECRET=<YOUR_CLIENT_SECRET>
  # AZURE_RESOURCE_GROUP=<YOUR_RESOURCE_GROUP>
  # AZURE_CLOUD_NAME=AzurePublicCloud

Apply the secrets:

kubectl apply -f bsl-creds.yaml -n velero
kubectl apply -f cloud-creds.yaml -n velero

6. Install Velero Using Helm

Use the following values.yaml. Both the source and destination vClusters use the same file:

configuration:
  backupStorageLocation:
    - name: default
      provider: azure
      bucket: velero
      config:
        resourceGroup: <YOUR_RESOURCE_GROUP>
        storageAccount: <YOUR_STORAGE_ACCOUNT>
        subscriptionId: <YOUR_SUBSCRIPTION_ID>
      credential:
        name: bsl-creds
        key: cloud

  volumeSnapshotLocation:
    - name: default
      provider: azure
      config:
        resourceGroup: <YOUR_RESOURCE_GROUP>
        subscriptionId: <YOUR_SUBSCRIPTION_ID>
      credential:
        name: cloud-creds
        key: cloud

credentials:
  useSecret: true
  existingSecret: cloud-creds

deployNodeAgent: true

nodeAgent:
  podVolumePath: /var/lib/kubelet/pods
  privileged: true

Install the Helm chart:

helm install velero vmware-tanzu/velero 
  --namespace velero 
  -f values.yaml

Once installed, you will see velero and node-agent pods running in the velero namespace:

kubectl get pods -n velero

Repeat the same Velero installation steps in the destination vCluster.

Backup and Restore a Sample MySQL Pod

Deploy MySQL in Source vCluster

mysql-pod.yaml:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: mysql-pvc
  namespace: default
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi
---
apiVersion: v1
kind: Pod
metadata:
  name: mysql-pod
  namespace: default
  labels:
    app: mysql
spec:
  containers:
    - name: mysql
      image: mysql:8.0
      env:
        - name: MYSQL_ROOT_PASSWORD
          value: rootpassword
        - name: MYSQL_DATABASE
          value: testdb
      volumeMounts:
        - name: mysql-storage
          mountPath: /var/lib/mysql
  volumes:
    - name: mysql-storage
      persistentVolumeClaim:
        claimName: mysql-pvc

Apply the manifest:

kubectl apply -f mysql-pod.yaml

Add Test Data

Exec into the pod:

kubectl exec -it mysql-pod -- /bin/bash

Run the following commands inside the pod to add test files:

echo "test data 1" > /var/lib/mysql/test1.txt
echo "test data 2" > /var/lib/mysql/test2.txt

This creates test1.txt and test2.txt.

Take a Backup

velero backup create mysql-backup 
  --include-namespaces default 
  --default-volumes-to-fs-backup 
  --wait

Check backup status:

velero backup get

The backup status should show Completed.

Restore in Destination vCluster

Update values.yaml for Destination

Make sure the Velero config is the same as the source. Use the same values.yaml, but update these two parameters:

# Change these in values.yaml for destination cluster
configuration:
  backupStorageLocation:
    - name: default
      # Keep all values the same as source — point to the same blob container
      accessMode: ReadOnly   # Destination reads from source's storage

After Velero is installed at the destination vCluster, verify you can see the source backups:

velero backup get

You will see the same backup list as the source vCluster.

Create a Restore

restore.yaml:

apiVersion: velero.io/v1
kind: Restore
metadata:
  name: mysql-restore
  namespace: velero
spec:
  backupName: mysql-backup
  includedNamespaces:
    - default
  restorePVs: true
  itemOperationTimeout: 4h

Apply the restore:

kubectl apply -f restore.yaml -n velero

Check restore status:

velero restore get
velero restore describe mysql-restore --details

To verify the restore, attach the PVC (created after restore completes) to a pod, exec into it, and confirm the data (test1.txt and test2.txt) is present.

Troubleshooting Tips

Issue 1: Backup status is PartiallyFailed or FailedValidation

Solution: Describe the backup for details:

velero backup describe mysql-backup --details

Check the backup logs:

velero backup logs mysql-backup

If nothing useful appears, check the Velero pod logs:

kubectl logs -n velero deployment/velero | grep mysql-backup

After running the above three commands, you’ll likely find the root cause. Common causes include permission issues or incorrect credentials. Sometimes partial failures occur because the node-agent pod isn’t running on a node — in that case, manually schedule a pod on that node.

Issue 2: Node Agent Pod is Not Running

node-agent-xxxxx   0/1   Pending   0   5m

Solution: There is a node with no pods running on it, so the node-agent DaemonSet pod is also not scheduled. Manually schedule a sample pod on that node to trigger scheduling. Once a sample pod is running, the node-agent pod will also be scheduled and start running.

Issue 3: Restore Fails Without Specific Errors

Solution: Restart the restore process from scratch:

  1. Delete all resources created by the restore job (pods, statefulsets, deployments, PVCs, etc.)

    OR

    If restoring a whole namespace, delete the entire restored namespace.

  2. Delete the restore job:

velero restore delete mysql-restore
  1. After the restore job is deleted, ArgoCD (if used) will automatically sync and recreate the restore job, triggering the Velero restoration.

Conclusion

Using Velero to back up and restore workloads across vClusters provides a robust and flexible approach for managing multi-tenant Kubernetes environments. Whether you’re migrating applications between development and production, setting up disaster recovery, or replicating environments for testing, Velero simplifies the process significantly.

In this blog post, we explored how to back up and restore Kubernetes clusters using Velero. While the process is straightforward in principle, production environments can introduce added complexity — factors like cluster size, workloads, and configurations often make a difference.

Originally published at improving.com

Building CDDBS — Part 3: Scoring LLM Output Without Another LLM

The Quality Problem

Here’s a dirty secret about LLM-powered applications: the hardest part isn’t generating output. It’s knowing whether the output is good.

You could use a second LLM to evaluate the first one. Some systems do this — “LLM-as-judge” is a popular pattern. But it has a fundamental flaw for intelligence work: LLMs are confidently wrong in correlated ways. If Gemini hallucinates a claim, GPT-4 reviewing that claim might accept it as plausible because it lacks the same context Gemini lacked. You’ve just automated the rubber stamp.

CDDBS takes a different approach: structural quality scoring. We don’t ask “is this briefing accurate?” (that requires ground truth we don’t have). We ask “does this briefing follow the structural rules that make intelligence products trustworthy?” That’s a question we can answer deterministically, with zero LLM calls.

The 7-Dimension Rubric

The quality scorer evaluates every briefing across 7 dimensions, each worth 10 points:

Dimension What It Measures Why It Matters
Structural Completeness All 7 required sections present Missing sections = incomplete analysis
Attribution Quality Claims linked to typed evidence Unattributed claims are unverifiable
Confidence Signaling Uncertainty expressed explicitly False certainty is the #1 failure mode
Evidence Presentation Evidence structured and specific Vague evidence is useless evidence
Analytical Rigor Sound reasoning, limitations noted Prevents overreach and tunnel vision
Actionability Findings are useful to an analyst A briefing nobody can act on has no value
Readability Clear, professional prose Technical accuracy means nothing if it’s unreadable

Total: 70 points. Ratings map to bands:

60-70  →  Excellent
50-59  →  Good
40-49  →  Acceptable
30-39  →  Poor
 0-29  →  Failing

Why These Dimensions?

This rubric came from Sprint 1 research. We analyzed briefing formats from 10 professional intelligence organizations: EUvsDisinfo, DFRLab (Atlantic Council), Bellingcat, NATO StratCom COE, Stanford Internet Observatory, Graphika, RAND Corporation, UK DCMS, the Global Engagement Center, and the Oxford Internet Institute.

Key finding: only 3 of 10 organizations use explicit confidence signaling in their public outputs. Per-finding confidence levels — where each claim has its own confidence score — is a CDDBS innovation. The rubric is designed to reward this practice because it’s the single most important quality signal for an analyst consuming the briefing.

Scoring Implementation

Let’s walk through how each dimension is scored in practice.

Structural Completeness (10 points)

The simplest dimension: does the briefing contain the sections we asked for?

def score_structural_completeness(briefing_text):
    score = 0
    issues = []
    required_sections = [
        "executive summary", "key findings", "subject profile",
        "narrative analysis", "confidence assessment",
        "limitations", "methodology"
    ]

    text_lower = briefing_text.lower()
    for section in required_sections:
        if section in text_lower:
            score += 1
        else:
            issues.append(f"Missing section: {section}")

    # Bonus points for structured formatting
    if "##" in briefing_text or "**" in briefing_text:
        score += min(3, 10 - score)  # up to 3 bonus for formatting

    return min(score, 10), issues

This catches the most common LLM failure: omitting sections. Gemini reliably produces Executive Summary and Key Findings but sometimes drops Limitations or Methodology — the sections that constrain analyst overconfidence.

Attribution Quality (10 points)

This is where the evidence typing system pays off:

EVIDENCE_TYPES = ["[POST]", "[PATTERN]", "[NETWORK]", "[METADATA]",
                  "[EXTERNAL]", "[FORWARD]", "[CHANNEL_META]"]

def score_attribution_quality(briefing_text):
    score = 0
    issues = []

    # Count evidence-typed attributions
    evidence_count = sum(
        briefing_text.count(etype) for etype in EVIDENCE_TYPES
    )

    if evidence_count >= 8:
        score += 4
    elif evidence_count >= 4:
        score += 2
    else:
        issues.append(f"Only {evidence_count} typed evidence items")

    # Check that findings have evidence
    findings = re.findall(
        r'(?:finding|key finding)[:s]*(.*?)(?=nn|n#|$)',
        briefing_text, re.IGNORECASE | re.DOTALL
    )
    findings_with_evidence = sum(
        1 for f in findings
        if any(et in f for et in EVIDENCE_TYPES)
    )

    if findings and findings_with_evidence / len(findings) >= 0.8:
        score += 3
    elif findings and findings_with_evidence / len(findings) >= 0.5:
        score += 1
    else:
        issues.append("Most findings lack typed evidence")

    # Check evidence specificity
    if re.search(r'[PATTERN].*d+%', briefing_text):
        score += 2  # PATTERN has specific metrics
    if re.search(r'[NETWORK].*@w+', briefing_text):
        score += 1  # NETWORK names specific accounts

    return min(score, 10), issues

The rubric rewards specific evidence. A [PATTERN] tag alone is worth something, but [PATTERN] 78% of tweets are retweets from state media is worth more. The regex checks for numbers after PATTERN tags and account names after NETWORK tags.

Confidence Signaling (10 points)

The most important dimension for intelligence work:

CONFIDENCE_LEVELS = ["high confidence", "moderate confidence",
                     "low confidence"]

def score_confidence_signaling(briefing_text):
    score = 0
    issues = []
    text_lower = briefing_text.lower()

    # Overall confidence stated
    has_overall = any(level in text_lower for level in CONFIDENCE_LEVELS)
    if has_overall:
        score += 3
    else:
        issues.append("No overall confidence level stated")

    # Per-finding confidence
    findings_section = extract_section(briefing_text, "key findings")
    if findings_section:
        confidence_mentions = sum(
            findings_section.lower().count(level)
            for level in CONFIDENCE_LEVELS
        )
        if confidence_mentions >= 3:
            score += 3
        elif confidence_mentions >= 1:
            score += 1

    # Confidence factors documented
    if "confidence" in text_lower and "factor" in text_lower:
        score += 2

    # No forbidden certainty language
    forbidden = ["obviously", "it is clear that", "definitely",
                 "without a doubt", "undeniably"]
    violations = [f for f in forbidden if f in text_lower]
    if not violations:
        score += 2
    else:
        issues.append(f"Forbidden certainty language: {violations}")

    return min(score, 10), issues

This dimension has a dual mechanism: it rewards explicit uncertainty (confidence levels, factors, caveats) and penalizes false certainty (forbidden phrases). An LLM that says “we assess with moderate confidence” gets full marks. One that says “it is clear that” gets docked.

Narrative Matching: The Other Evaluation Layer

Quality scoring tells you whether the briefing is structurally sound. Narrative matching tells you what it found.

The Narrative Database

CDDBS maintains a JSON file of 18 known disinformation narratives:

{
  "narratives": [
    {
      "id": "ukraine_001",
      "name": "Ukraine as Nazi/Fascist State",
      "category": "Ukraine Conflict Revisionism",
      "keywords": [
        "nazi", "fascist", "azov", "denazification",
        "bandera", "neo-nazi", "ultranationalist",
        "right sector"
      ],
      "description": "Claims that Ukraine is controlled by Nazi or fascist elements..."
    }
  ]
}

Each narrative has a unique ID, a category, and a keyword list. The keywords are chosen to be specific enough to avoid false positives on general political discussion.

The Matching Algorithm

def match_narratives(text, threshold=2):
    narratives = load_known_narratives()
    matches = []
    text_lower = text.lower()

    for narrative in narratives:
        matched_keywords = [
            kw for kw in narrative["keywords"]
            if kw.lower() in text_lower
        ]

        if len(matched_keywords) >= threshold:
            confidence = (
                "high" if len(matched_keywords) >= 5
                else "moderate" if len(matched_keywords) >= 3
                else "low"
            )
            matches.append({
                "narrative_id": narrative["id"],
                "narrative_name": narrative["name"],
                "category": narrative["category"],
                "confidence": confidence,
                "matched_keywords": matched_keywords,
                "match_count": len(matched_keywords)
            })

    # Deduplicate: keep strongest match per narrative
    seen = {}
    for match in matches:
        nid = match["narrative_id"]
        if nid not in seen or match["match_count"] > seen[nid]["match_count"]:
            seen[nid] = match

    return list(seen.values())

Why Keyword Matching, Not ML?

This is a deliberate design choice with real trade-offs:

Advantages of keyword matching:

  • Deterministic. Same input always produces same output.
  • Auditable. You can see exactly which keywords triggered the match.
  • Fast. No model loading, no inference time. Runs in <10ms.
  • Offline. No external service dependency.
  • Explainable. An analyst can evaluate whether the match is a true positive by reading the keywords.

Disadvantages:

  • No contextual understanding. “NATO expansion” in a factual news report about a summit and “NATO expansion” in a conspiracy theory about Russian encirclement produce the same match.
  • Keyword coverage. If a narrative evolves to use new language, the keywords need manual updating.
  • No semantic similarity. Paraphrases of known narratives won’t match.

For CDDBS’s use case, the advantages win. The system is a tool for analysts, not a replacement for them. A false positive with an explanation (“matched on: nazi, azov, denazification”) is more useful than an ML prediction with a probability score that can’t be interrogated.

Confidence Calibration

The threshold system maps keyword density to confidence:

5+ keywords matched  →  High confidence
3-4 keywords matched →  Moderate confidence
2 keywords matched   →  Low confidence
1 keyword matched    →  Below threshold (not reported)

The minimum threshold of 2 is critical. A single keyword like “NATO” could appear in any geopolitical article. Two keywords from the same narrative — “NATO” + “encirclement” — is a much stronger signal.

Putting It Together: A Scored Report

Here’s what the quality + narrative pipeline produces for a hypothetical RT analysis:

{
  "quality": {
    "total_score": 52,
    "rating": "Good",
    "dimensions": {
      "structural_completeness": {"score": 8, "max": 10},
      "attribution_quality": {"score": 7, "max": 10},
      "confidence_signaling": {"score": 8, "max": 10},
      "evidence_presentation": {"score": 7, "max": 10},
      "analytical_rigor": {"score": 8, "max": 10},
      "actionability": {"score": 7, "max": 10},
      "readability": {"score": 7, "max": 10}
    }
  },
  "narratives": [
    {
      "narrative_id": "ukraine_003",
      "name": "Western Provocation Caused Conflict",
      "category": "Ukraine Conflict Revisionism",
      "confidence": "high",
      "matched_keywords": ["NATO", "Maidan", "provocation", "coup", "Western"],
      "match_count": 5
    },
    {
      "narrative_id": "anti_nato_001",
      "name": "NATO Expansion Threatens Russia",
      "confidence": "moderate",
      "matched_keywords": ["NATO expansion", "encirclement", "broken promises"],
      "match_count": 3
    }
  ]
}

The analyst sees a 52/70 “Good” rating, knows which dimensions are weak (actionability and readability, both 7/10), and sees two narrative matches with the specific keywords that triggered them. They can then verify: did the articles actually discuss Maidan as a Western-backed coup, or was the keyword match coincidental?

Frontend: Making Scores Useful

Quality scores are only valuable if analysts can interpret them. The frontend renders two key visualizations:

Quality Radar Chart. A custom SVG heptagon (7-sided) showing all dimensions simultaneously. No charting library dependency — just computed SVG paths:

        Structural (8)
           ╱  ╲
    Read (7)    Attrib (7)
       │          │
   Action (7)  Confid (8)
       │          │
    Rigor (8)  Evidence (7)

Narrative Tags. Color-coded pills that expand to show matched keywords:

[Ukraine Conflict Revisionism] ● High
  → NATO, Maidan, provocation, coup, Western

[Anti-NATO] ● Moderate
  → NATO expansion, encirclement, broken promises

The Testing Strategy

Quality scoring has the highest test density in the codebase: 23 tests covering all 7 dimensions plus edge cases.

# test_quality.py examples
def test_high_quality_briefing():
    """A well-structured briefing should score 50+."""
    score = score_briefing_text(HIGH_QUALITY_FIXTURE)
    assert score["total_score"] >= 50
    assert score["rating"] in ("Good", "Excellent")

def test_minimal_briefing():
    """A briefing with only basic structure should score 25-40."""
    score = score_briefing_text(MINIMAL_FIXTURE)
    assert 25 <= score["total_score"] <= 40
    assert score["rating"] in ("Poor", "Acceptable")

def test_forbidden_language_penalty():
    """Forbidden certainty language should reduce confidence score."""
    text = "It is clear that this account is spreading propaganda."
    score = score_confidence_signaling(text)
    assert score < 8  # penalty applied

The tests use fixture files — real briefing examples at different quality levels (high, medium, low, minimal, telegram, cross-platform). This ensures the scorer produces sensible results across the full range of inputs.

Narrative matching has 11 tests covering keyword thresholds, confidence calibration, deduplication, and edge cases:

def test_below_threshold():
    """A single keyword should not trigger a match."""
    matches = match_narratives("NATO held a summit today.")
    assert len(matches) == 0

def test_moderate_confidence():
    """3-4 keywords should produce moderate confidence."""
    text = "NATO expansion and encirclement, with broken promises"
    matches = match_narratives(text)
    assert matches[0]["confidence"] == "moderate"

Design Principle: Evaluate Structure, Not Truth

The core insight behind CDDBS’s quality system is that you can evaluate process quality without evaluating factual accuracy. A briefing that:

  • Includes all 7 sections
  • Attributes every claim to typed evidence
  • States confidence levels explicitly
  • Acknowledges limitations
  • Uses professional language

…is more likely to be accurate than one that doesn’t. Not because structure guarantees truth, but because the structural requirements force the LLM to do the work that produces accurate output. You can’t write [POST] https://twitter.com/... without having a specific post to reference. You can’t write “We assess with moderate confidence” without implicitly acknowledging uncertainty.

The quality scorer doesn’t grade the LLM’s homework. It checks whether the LLM showed its work.

Quality scorer implementation: quality.py. Narrative database: known_narratives.json.

I stopped setting up TypeScript APIs from scratch — here’s what I do instead

Every new project starts the same way.

Open terminal. mkdir new-project. And then… 2 days of configuration before writing a single line of business logic.

After 8 years building APIs professionally — across startups and companies — I realized I was solving the same problems every single time:

  • TypeScript strict mode setup that actually works
  • JWT auth with refresh token rotation
  • A folder structure that doesn’t fall apart at scale
  • Docker that works the same on every machine
  • CI/CD that doesn’t take half a day to configure

So I stopped. And I packaged everything into a boilerplate I actually use in production.

What the stack looks like

After trying many combinations, this is what I landed on:

Fastify over Express — it’s faster, has better TypeScript support out of the box, and the plugin system is genuinely good once you understand it.

Prisma over raw SQL or other ORMs — the DX is excellent and the generated types save hours of manual typing.

Zod for validation — schema-first validation that integrates cleanly with TypeScript. No more runtime surprises.

Vitest over Jest — faster, same API, zero config with TypeScript.

The folder structure

This is the part most boilerplates get wrong. Everything dumped in a flat src/ folder doesn’t scale.

src/
  config/       # Zod-validated env vars, constants
  modules/      # Feature modules
    auth/       # routes, controller, service, schema
    users/      # routes, controller, service, schema
  middlewares/  # error handler, auth guard, rate limiter
  plugins/      # Fastify plugins (Prisma, JWT)
  utils/        # AppError, response helpers, logger
  app.ts
  server.ts

Each module owns its routes, controller, service and validation schema. Adding a new feature means adding a new folder — nothing else changes.

Auth that actually works in production

Most boilerplates ship with basic JWT. This one has:

  • Access token (short-lived) + refresh token (long-lived)
  • Refresh tokens stored in DB and rotated on every use
  • Role-based access control: authenticate() and authorize(ROLES.ADMIN) guards
  • Passwords hashed with Node.js native scrypt — no bcrypt dependency
// Protecting a route is two lines
fastify.get(
  '/admin/users',
  { preHandler: [authenticate, authorize(ROLES.ADMIN)] },
  usersController.findAll
)

Error handling that doesn’t leak internals

Centralized error handler with a custom AppError class:

// Throw anywhere in your code
throw new AppError('User not found', 404)

// Consistent response format everywhere
// { success: false, message: 'User not found', statusCode: 404 }

Zod validation errors are also caught and formatted automatically — no more unhandled validation crashes.

From zero to running API in 4 commands

cp .env.example .env
docker compose up db -d
npx prisma migrate dev
npm run dev

That’s it. PostgreSQL running in Docker, migrations applied, API live on localhost.

CI/CD included

GitHub Actions pipeline with 4 stages:

  1. Lint — ESLint + Prettier
  2. Test — Vitest + Supertest integration tests
  3. Build — TypeScript compilation check
  4. Docker — multi-stage build (builder + alpine runner, non-root user)

Push to main and everything runs automatically.

The result

38 files. Zero any types. Strict TypeScript throughout. Consistent response format. Graceful shutdown. Pino logger with request ID tracing.

Everything I wished existed when I started my first project.

If you want to skip the setup and start with this foundation, I packaged it here: [link to your Gumroad product]

Or just steal the ideas — either way, I hope it saves you 2 days.

How to Stop Your OpenAI API Bill from Spiraling Out of Control

How to Stop Your OpenAI API Bill from Spiraling Out of Control

If you’re building with LLM APIs (OpenAI, Anthropic Claude, Google Gemini), you’ve probably had that moment — you check your dashboard and realize a runaway loop or an uncapped user session just burned through your entire monthly budget in minutes.

There’s no built-in way to set spending limits across these SDKs. OpenAI’s usage limits are post-hoc (money already spent), Anthropic has no budget controls, and Gemini has rate limits but not token budgets.

I built llm-spend-guard to fix this.

What It Does

It wraps your existing LLM SDK calls and enforces token budgets before any request is sent. If a request would exceed your budget, it gets blocked instantly — the API is never called, no money wasted.

Your Code --> llm-spend-guard --> LLM API
                  |
                  ├── Estimates tokens BEFORE the request
                  ├── Checks budget (global, per-user, per-session)
                  ├── Over budget? BLOCKS the request
                  └── Under budget? Sends request, tracks usage

Quick Start (3 Lines)

import { LLMGuard } from 'llm-spend-guard';
import OpenAI from 'openai';

const guard = new LLMGuard({ dailyBudgetTokens: 100_000 });

const openai = new OpenAI();
guard.wrapOpenAI(openai);

// Use guard.openai instead of openai
const response = await guard.openai.chat({
  model: 'gpt-4o',
  messages: [{ role: 'user', content: 'Hello!' }],
  max_tokens: 500,
});

If this request would exceed 100K tokens for the day, it throws BudgetExceededError and OpenAI is never called.

Works with All Major Providers

guard.wrapOpenAI(openai);       // GPT-4o, GPT-4, etc.
guard.wrapAnthropic(anthropic); // Claude
guard.wrapGemini(gemini);       // Gemini 1.5 Pro

All three share the same budget pool or can have separate limits.

Per-User Budgets (For SaaS Apps)

This is where it gets powerful. If you’re building a multi-tenant app:

const guard = new LLMGuard({
  dailyBudgetTokens: 1_000_000,  // 1M total across all users
  userBudgetTokens: 10_000,      // 10K per user per day
  storage: new RedisStorage(new Redis()),
});

await guard.openai.chat(
  { model: 'gpt-4o', messages, max_tokens: 500 },
  { userId: req.user.id }  // per-user tracking
);

Express.js Middleware

import { expressMiddleware, budgetErrorHandler } from 'llm-spend-guard';

app.use(expressMiddleware(guard));  // auto-extracts userId, sessionId
app.use(budgetErrorHandler);       // returns 429 when budget exceeded

Key Features

  • Pre-request blocking — No money spent on over-budget requests
  • Multi-provider — OpenAI + Anthropic Claude + Google Gemini
  • Multi-scope — Global, per-user, per-session, per-route budgets
  • Auto-truncation — Intelligently trims prompts to fit remaining budget
  • Redis support — Production-ready with shared state across instances
  • Express/Next.js — Built-in middleware
  • TypeScript-first — Full type safety
  • 18.6KB — Lightweight, single dependency (tiktoken)

Links

If you’ve been bitten by surprise LLM bills, give it a try and let me know what you think!