How to Detect When a Competitor Launches a New Feature Before Your Sales Team Notices

Your sales rep is halfway through a demo when the prospect asks: “Can your product do X? We heard [a competitor] just added that last month.”

You had no idea.

It’s one of the most deflating moments in B2B sales — and it’s almost entirely preventable with a two-hour automation setup. This article shows you how to build it.

The Problem: Competitor Product Intelligence Has a Structural Gap

Most B2B SaaS teams have no real-time monitoring for competitor product changes. They rely on:

  • Sales reps to surface insights from customer conversations (reactive, weeks late)
  • Marketing team members to manually check competitor websites (inconsistent, unmaintained)
  • Paid competitive intelligence tools that often lag by days and cost $200–$500/month
  • Social media monitoring that misses silent changelog updates entirely

The result: your team learns about competitor feature launches from prospects, not from internal intelligence. By then, the deal may already be tilted.

The root cause isn’t effort — it’s structure. Competitor product pages, changelogs, and blogs change without notifying you. There’s no RSS feed for competitor product decisions. No Slack integration that fires when a rival ships a major update. You have to pull that data — and almost nobody has a system to do it automatically.

Why Manual Monitoring Breaks Down

Let’s be specific about where the gap is:

Changelog pages. Most SaaS companies maintain a changelog at /changelog or /releases. These pages are updated on irregular schedules — sometimes weekly, sometimes after a silent 6-week freeze followed by a burst of three major features. Nobody on your team checks this every week.

Product/feature pages. Feature landing pages quietly gain new sections, new capability descriptions, new comparison tables. A “Basic plan” quietly becomes “Starter + AI Assistant.” You won’t notice unless you’re watching.

Blog posts. Feature launch announcements typically go out as blog posts first — before the feature even fully ships. A blog post titled “Introducing [Feature Category]” is often your earliest warning signal, and it’s completely searchable.

SERP visibility. When a competitor is about to ship, they often start appearing in new search queries. Monitoring their brand name alongside product terms (“new feature,” “launch,” “update”) gives you early signal before the blog post even ranks.

None of this is hidden. All of it is publicly accessible. The problem is that pulling and comparing it at scale requires infrastructure most teams don’t have.

The Defense Pattern: Structured Change Detection

Here’s the architecture that works:

  1. Extract — pull the full text content of competitor changelog pages, product pages, and blog post listings weekly
  2. Diff — compare today’s content to last week’s stored snapshot
  3. Alert — when new sections or new posts appear, fire a Slack message or email to your product/sales lead

The key insight is that you don’t need to read competitor pages — you need to detect changes in them, and then have a human read only the changed sections. That’s a five-minute weekly review instead of a 30-minute manual audit.

Implementation: Apify + JavaScript

Apify provides two actors that handle this well on a free tier:

  • apify/website-content-crawler — extracts clean text from any webpage, including JS-rendered content
  • lanky_quantifier/google-serp-scraper — runs Google searches and returns structured results

Here’s a working Node.js implementation:

import { ApifyClient } from 'apify-client';
import * as fs from 'fs';
import * as crypto from 'crypto';

const client = new ApifyClient({ token: process.env.APIFY_API_TOKEN });

// Pages to monitor — add competitor changelogs, feature pages, blog indexes
const PAGES_TO_MONITOR = [
  'https://competitor-a.com/changelog',
  'https://competitor-a.com/features',
  'https://competitor-b.com/whats-new',
  'https://competitor-b.com/blog',
];

// SERP queries to monitor for launch signals
const SERP_QUERIES = [
  '"competitor-a" new feature 2024',
  '"competitor-a" launch announcement',
  '"competitor-b" product update',
];

async function extractPageContent(url) {
  const run = await client.actor('apify/website-content-crawler').call({
    startUrls: [{ url }],
    maxCrawlPages: 1,
    outputFormats: ['text'],
  });

  const { items } = await client.dataset(run.defaultDatasetId).listItems();
  return items[0]?.text || '';
}

async function checkSerpForLaunchSignals(query) {
  const run = await client.actor('lanky_quantifier/google-serp-scraper').call({
    queries: query,
    maxPagesPerQuery: 1,
    resultsPerPage: 10,
  });

  const { items } = await client.dataset(run.defaultDatasetId).listItems();
  return items.filter(r => r.position <= 5).map(r => ({
    title: r.title,
    url: r.url,
    snippet: r.snippet,
  }));
}

function hashContent(text) {
  return crypto.createHash('sha256').update(text).digest('hex');
}

function loadSnapshot() {
  if (fs.existsSync('./competitor-snapshot.json')) {
    return JSON.parse(fs.readFileSync('./competitor-snapshot.json', 'utf-8'));
  }
  return {};
}

function saveSnapshot(snapshot) {
  fs.writeFileSync('./competitor-snapshot.json', JSON.stringify(snapshot, null, 2));
}

async function runMonitor() {
  const snapshot = loadSnapshot();
  const changes = [];
  const newSnapshot = {};

  // Check competitor pages for content changes
  for (const url of PAGES_TO_MONITOR) {
    console.log(`Checking: ${url}`);
    const content = await extractPageContent(url);
    const hash = hashContent(content);
    newSnapshot[url] = { hash, checkedAt: new Date().toISOString() };

    if (snapshot[url] && snapshot[url].hash !== hash) {
      changes.push({
        type: 'PAGE_CHANGED',
        url,
        message: `Content changed on ${url}`,
      });
    }
  }

  // Check SERP for launch signals
  for (const query of SERP_QUERIES) {
    const results = await checkSerpForLaunchSignals(query);
    const topResult = results[0];
    if (topResult) {
      const key = `serp:${query}`;
      const topUrl = topResult.url;
      newSnapshot[key] = { topUrl, checkedAt: new Date().toISOString() };

      if (snapshot[key] && snapshot[key].topUrl !== topUrl) {
        changes.push({
          type: 'SERP_CHANGED',
          query,
          newTop: topResult,
          message: `New top result for query: "${query}"`,
        });
      }
    }
  }

  saveSnapshot(newSnapshot);

  if (changes.length > 0) {
    console.log('n🚨 COMPETITOR CHANGES DETECTED:');
    changes.forEach(c => console.log(`  - ${c.message}`));
    await sendAlert(changes);
  } else {
    console.log('n✅ No changes detected this week.');
  }
}

async function sendAlert(changes) {
  const webhookUrl = process.env.SLACK_WEBHOOK_URL;
  if (!webhookUrl) return;

  const text = changes.map(c => `• *${c.type}*: ${c.message}`).join('n');
  await fetch(webhookUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: `🔍 *Competitor Intelligence Alert*nn${text}nnReview manually and route to product/sales.`,
    }),
  });
}

runMonitor().catch(console.error);

Scheduling the Monitor

Run this weekly using Apify’s built-in scheduler or a cron job:

# cron entry — runs every Monday at 8am
0 8 * * 1 cd /your/project && node monitor.js >> /var/log/competitor-monitor.log 2>&1

Or schedule it directly in the Apify console as a recurring Task — no server required.

What to Monitor

Start with these page types for maximum signal density:

Page Type Why It Matters
/changelog or /releases Direct feature shipping signal
/features or /product Quiet capability expansions
/blog (index page) First announcement vehicle
SERP: "brand" + "new" + "feature" Pre-launch SEO positioning
SERP: "brand" + "vs" + "your-brand" Comparison page updates

Three to five competitors, four to six pages each — that’s 15–30 monitored URLs. Apify’s free tier handles this easily.

Cost

Apify free tier: $5/month credit, renewing monthly. A weekly run monitoring 20–30 URLs via website-content-crawler costs roughly $0.10–$0.30 per run. You’ll stay within free tier limits.

The alternative cost: One lost deal to a competitor feature you didn’t know existed can be worth $5,000–$50,000 in ARR. The monitoring pays for itself the first time it fires.

What You Actually Get

The system fires when something changes. A human then spends five minutes reading the changed section and deciding whether to act. That’s the design — automated detection, human judgment on significance.

No more learning about competitor launches from prospects. No more scrambling to prepare objection handling the day before a major demo. Your sales team gets early warning. Your product team gets signal on what your market values enough to ship.

The gap between “reactive” and “proactive” competitive intelligence is two hours of setup time and $0 in monthly cost.

This tutorial uses Apify actors available at apify.com. The website-content-crawler and google-serp-scraper actors are free-tier compatible. No API key required for the Apify free plan — create an account and you’re ready to run.

I built an open-source system to track how engineers actually adapt to AI

The problem

Everyone has opinions about what engineers should do in response to AI. Almost no one has data about what they actually do.

I wanted data.

What I built

HumanExodus is a longitudinal observation system. It captures how engineers respond to AI pressure at the moment it’s happening, then follows up 30 days later to find out what actually happened.

The gap between intention and reality is the dataset.

How it works

  1. Engineer enters role, experience, and tech stack
  2. Rule-based engine estimates AI exposure level (HIGH / MEDIUM / LOW)
  3. Claude API generates personalised adjacent moves based on their profile
  4. Engineer selects their intended next step
  5. Session saved to Supabase
  6. 30 days later: automated email via Resend asks what actually happened
  7. Outcome saved as a follow-up record

What we’re seeing so far

With just 11 sessions, a pattern is already emerging:

  • HIGH exposure engineers: high uncertainty (40% “not sure”)
  • MEDIUM exposure engineers: mostly staying put (80% “stay same”)
  • LOW exposure engineers: 100% staying same

This is early and small. But it’s real behavioural data, not survey responses.

The tech stack

  • Frontend: single HTML file, no build step, no framework
  • Database: Supabase (Postgres)
  • AI: Claude API for move generation
  • Email: Resend
  • Hosting: GitHub Pages

The moat

The code is simple. The data is not.

If this works, HumanExodus becomes the largest structured dataset of how engineers adapt to AI — role by role, stack by stack, over time.

What’s next

  • v0.5: Predictive layer — “people like you tend to move into X within 30-60 days”
  • But only after real follow-up data exists

Try it / contribute

Building a Desktop Control Center for OpenClaw with Tauri and Rust

Analytics dashboard

I’ve been working with OpenClaw for managing AI agents, and I kept running into the same problems: juggling multiple instances for different environments, manually editing openclaw.json, and lacking visibility into token usage and costs.

So I built Pond — a desktop app that sits on top of OpenClaw and solves these issues.

Screenshots (from the repo README)

Analytics dashboard

Role management

Team tasks

Agent chat

What it does

Pond is a control center that lets you:

  • Manage multiple OpenClaw instances on one machine (like dev/test/prod environments)
  • Orchestrate team collaboration with a built-in task state machine (open → claimed → done/failed)
  • Monitor in real-time — tokens, costs, sessions, system resources
  • Edit configs visually instead of wrestling with JSON files

Each instance runs independently with its own Gateway process, port, config, and team data.

Technical stack

I chose Tauri 2 instead of Electron because I wanted native performance and smaller binaries. The backend is Rust:

  • tokio::process for managing Gateway subprocesses
  • fs4 for cross-platform file locks (team data concurrency)
  • Native WebSocket plugin for bidirectional streaming with OpenClaw

The frontend is React 19 + TypeScript + Zustand for state, with Radix UI components and TailwindCSS.

The team collaboration layer

One unique feature is the collaboration workflow. You define roles in agents.list (a Leader and executors), and Pond manages a task lifecycle:

  1. Leader creates tasks (state: open)
  2. Executors claim tasks (state: claimed)
  3. Executors mark them done or failed with reasons
  4. Real-time notifications pushed via WebSocket to relevant roles

There’s a built-in pond-team skill that orchestrates this entire workflow without manual coordination.

What’s missing

It’s still early (v1.0.4), and there are gaps:

  • No cloud sync for configs yet
  • No mobile clients (only desktop)
  • Skill marketplace is planned but not built

But the core features work, and it’s fully open source.

Try it

Installers for macOS (Intel + Apple Silicon), Windows, and Linux are on the GitHub releases page:

👉 https://github.com/tageecc/pond

If you’re building with OpenClaw, I’d appreciate feedback — especially around what metrics or workflows you need most.

Building a Hash Generator with Web Crypto API and a Pure-JS MD5 Fallback

Every developer runs into hashing at some point. Checking file integrity, storing password fingerprints, verifying API payloads, generating cache keys.

Most reach for a CLI tool (shasum, md5sum) or an npm package. But what if you could hash text and files directly in the browser — no installs, no dependencies, no server?

I built a Hash Generator that supports MD5, SHA-1, SHA-256, SHA-384, and SHA-512. All client-side. Here’s how it works under the hood.

The Web Crypto API Does Most of the Work

Modern browsers ship with crypto.subtle — a native cryptographic API that handles SHA algorithms with hardware-accelerated performance.

async function computeHash(
  data: Uint8Array,
  algorithm: Algorithm
): Promise<string> {
  if (algorithm === "MD5") return md5(data);

  const hashBuffer = await crypto.subtle.digest(algorithm, data);
  return Array.from(new Uint8Array(hashBuffer))
    .map(b => b.toString(16).padStart(2, "0"))
    .join("");
}

That’s it for SHA-1, SHA-256, SHA-384, and SHA-512. crypto.subtle.digest() accepts the algorithm name and a BufferSource, returns an ArrayBuffer. Convert each byte to a two-character hex string, join them, done.

Three things worth noting:

  1. It’s async. digest() returns a Promise. For large files, this prevents UI freezing since the browser can offload computation.

  2. Algorithm names match directly. Web Crypto uses "SHA-256", "SHA-512", etc. — same strings we display in the UI.

  3. It’s fast. Native implementations are significantly faster than any JavaScript library. SHA-256 on a 10MB file takes milliseconds.

Why MD5 Needs a Pure-JS Implementation

Here’s the catch: Web Crypto doesn’t support MD5.

And it’s intentional. MD5 is cryptographically broken — collisions can be generated in seconds. The Web Crypto spec deliberately excludes it to discourage use in security contexts.

But MD5 is still useful. Checksums, legacy system compatibility, non-security fingerprinting. Developers need it.

So I wrote a ~50-line MD5 implementation directly in the component. No external library.

The MD5 Algorithm in JavaScript

MD5 processes data in 64-byte (512-bit) blocks. Each block goes through 64 rounds of bitwise operations across four auxiliary functions.

Step 1: Padding

The input must be padded to a multiple of 64 bytes, with the original bit length appended:

const len = input.length;
const bitLen = len * 8;
const padLen = ((56 - (len + 1) % 64) + 64) % 64;
const padded = new Uint8Array(len + 1 + padLen + 8);
padded.set(input);
padded[len] = 0x80; // Append 1-bit separator

// Little-endian 64-bit length
const view = new DataView(padded.buffer);
view.setUint32(padded.length - 8, bitLen >>> 0, true);
view.setUint32(padded.length - 4, Math.floor(bitLen / 0x100000000), true);

The 0x80 byte marks the end of the message. Then zeros fill until 56 bytes mod 64. Finally, the original message length (in bits) is written as a little-endian 64-bit integer.

Step 2: Block processing

Four state variables (a, b, c, d) are initialized to fixed values. Each 64-byte block modifies these through 64 rounds:

  • Rounds 0–15: F(b, c, d) = (b & c) | (~b & d)
  • Rounds 16–31: G(b, c, d) = (b & d) | (c & ~d)
  • Rounds 32–47: H(b, c, d) = b ^ c ^ d
  • Rounds 48–63: I(b, c, d) = c ^ (b | ~d)

Each round adds a constant from a precomputed table (k), adds a word from the block, applies a left rotation by a specified amount (s), and chains into the next round.

Step 3: Output

The final a, b, c, d values are concatenated as little-endian bytes and converted to hex:

[a, b, c, d].map(n =>
  [n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]
    .map(b => b.toString(16).padStart(2, "0"))
    .join("")
).join("");

Note the little-endian extraction — MD5 uses little-endian byte order, unlike SHA which uses big-endian. Getting this wrong produces valid-looking but incorrect hashes.

Why Not Use a Library?

An npm MD5 package would work fine. But it adds a dependency for ~50 lines of deterministic code. The algorithm hasn’t changed since 1992. There’s no bug fix or security patch coming.

Inlining it:

  • Eliminates the dependency
  • Reduces bundle size (~3-5KB saved)
  • Keeps everything in one file for easier maintenance

For a utility like this, fewer dependencies is better.

Normalizing Input: Text vs. Files

A hash function operates on bytes, not strings. So both text and file inputs need to become Uint8Array before hashing.

Text input:

const data = new TextEncoder().encode(value);

TextEncoder converts a JavaScript string to UTF-8 bytes. This matters because the same string can produce different hashes under different encodings. UTF-8 is the standard.

File input:

const buffer = await file.arrayBuffer();
const data = new Uint8Array(buffer);

File.arrayBuffer() reads the raw binary content. No encoding conversion — the bytes go directly to the hash function. This is critical for binary files (images, executables, archives).

By normalizing both paths to Uint8Array, the computeHash function doesn’t need to know where the data came from:

const generate = async (
  data: Uint8Array,
  algo: Algorithm,
  upper: boolean
) => {
  setIsProcessing(true);
  const hash = await computeHash(data, algo);
  setOutput(upper ? hash.toUpperCase() : hash);
  setIsProcessing(false);
};

Real-Time Hashing on Every Keystroke

Instead of a “Generate” button, the hash updates live as you type:

const handleTextChange = async (value: string) => {
  setInput(value);
  if (!value) { setOutput(""); return; }
  const data = new TextEncoder().encode(value);
  await generate(data, algorithm, uppercase);
};

This works because:

  1. Web Crypto’s digest() is fast enough for small inputs (sub-millisecond)
  2. The async pattern prevents blocking even if input gets large
  3. React’s state batching prevents unnecessary re-renders

Changing the algorithm also triggers recomputation with the existing input — so you can compare SHA-256 vs SHA-512 of the same text instantly.

Output Formatting

Two small but important details:

Hex padding:

b.toString(16).padStart(2, "0")

Without .padStart(2, "0"), a byte value of 0x0A would output "a" instead of "0a". This would produce hashes with inconsistent lengths — SHA-256 might be 63 characters instead of 64. Always pad.

Uppercase toggle:

Some tools output uppercase hex (A3B2C1), others lowercase (a3b2c1). Both are valid. The tool defaults to lowercase (more common) but offers a toggle. The hash is recomputed on toggle to maintain consistency.

SSR Pitfall: Web Crypto Is Browser-Only

crypto.subtle doesn’t exist on the server. In Next.js with SSR, this causes a build-time crash.

The fix:

const HashGenerator = dynamic(
  () => import("./HashGenerator").then((mod) => mod.HashGenerator),
  { ssr: false }
);

ssr: false ensures the component only renders in the browser where crypto.subtle is available. The page shell (SEO content, schema markup) still renders server-side.

Algorithm Comparison: When to Use What

Algorithm Output Speed Use Case
MD5 32 chars Fastest (JS) Checksums, cache keys, legacy
SHA-1 40 chars Fast Git commits (legacy), non-security
SHA-256 64 chars Fast File integrity, API signatures
SHA-384 96 chars Fast TLS, certificate chains
SHA-512 128 chars Fast Maximum security, password hashing input

For anything security-related, use SHA-256 or higher. MD5 and SHA-1 are fine for non-security purposes like deduplication or cache busting.

Try It

The live tool is at ultimatetools.io/tools/coding-tools/hash-generator/.

Paste text or drop a file. Switch between algorithms. Compare outputs. All processing stays in your browser.

If you’re building something similar, the key takeaways:

  1. Use crypto.subtle.digest() for SHA — it’s native, fast, and async
  2. Implement MD5 yourself if you need it — 50 lines, no dependency
  3. Normalize all input to Uint8Array before hashing
  4. Always padStart(2, "0") when converting bytes to hex
  5. Use ssr: false in Next.js — Web Crypto is browser-only

Built with Next.js, TypeScript, and zero external crypto libraries. Part of Ultimate Tools — a collection of free, privacy-first browser tools.

Microsoft’s Own VP Hates the Mandatory Account Requirement. The Teams That Profit From It Won’t Let Go.

Microsoft’s own VP just said “Ya I hate that. Working on it.” about the mandatory Microsoft Account requirement in Windows 11.

That’s Scott Hanselman. Vice President of Developer Community. The person Microsoft literally pays to make developers happy.

And he’s publicly admitting the setup experience for Windows 11 is broken.

Here’s the backstory

Windows 11 launched in October 2021. From day one, the Home edition forced you to create a Microsoft Account during setup. No internet? No local account? No computer.

By mid-2022, they extended the same requirement to Windows 11 Pro.

The developer community responded exactly how you’d expect. People started sharing workarounds like oobebypassnro in Command Prompt. Others used Rufus to create modified install media. Some disconnected their ethernet mid-setup just to get a local account.

Microsoft noticed. They started patching the workarounds. Killing bypass scripts. Removing the “I don’t have internet” button from preview builds.

It became an arms race between Microsoft and its own users.

Why this matters to devs

If you’ve ever set up a test VM, a CI runner, or a bare-metal build machine, you know this pain. You don’t want your throwaway dev environment tied to your personal Microsoft Account. You don’t want OneDrive syncing your temp files. You don’t want Copilot suggestions on a machine you’re wiping next week.

Enterprise customers get around this with domain joins and Intune. But individual developers, freelancers, and hobbyists? They’re stuck playing cat-and-mouse with setup hacks.

The real fight is internal

According to Windows Central, senior engineers inside Microsoft have been pushing to drop the requirement. Hanselman’s tweet wasn’t a solo act. Multiple employees reportedly want an “MSA-free” login option.

But here’s the friction. The teams that own advertising, product engagement metrics, and Microsoft 365 upsells benefit from every forced account creation. Every new setup is a data point, a potential subscription, a number on a quarterly slide.

It’s the classic platform tension. The people building the product want it to be good. The people measuring the product want it to convert.

The pattern is bigger than Windows

This isn’t just a Microsoft problem. It’s the same pattern everywhere.

→ Adobe won’t let you use Photoshop without Creative Cloud
→ JetBrains requires an account for IDE activation
→ Even VS Code’s Settings Sync nudges you toward GitHub or Microsoft login

The industry decided that accounts equal engagement and engagement equals revenue. Users became conversion metrics, not people trying to get work done.

What actually changed

Hanselman’s tweet landed on March 20, 2026. A week later, there’s no official announcement, no timeline, no committed plan.

The teams that benefit from mandatory accounts are reportedly resisting the change. So we have a Microsoft VP publicly saying “I hate that” about his own company’s product while other teams inside the same company fight to keep it.

If that doesn’t perfectly capture how large companies make product decisions, I don’t know what does.

The optimistic read is that enough internal pressure will eventually win. The realistic read is that engagement metrics rarely lose to developer experience in a boardroom.

What do you think? Should operating systems require an account to set up, or is that a line that should never have been crossed? 👇

Sprint 2 Preview: Five Decisions That Will Shape the Platform

Sprint 2 Preview: Five Decisions That Will Shape the Platform

The Journey So Far

This is the third post in a series documenting the ORCHESTRATE Marketing Platform build — an AI-agent-driven project where every line of code is written through Documentation-Driven Test-Driven Development (DD TDD), enforced by an MCP server that mechanically blocks methodology violations.

Sprint 0 was pure infrastructure. No features. No UI. Just the foundation: V2 migration bridge with SHA-256 audit trail, backup manager with integrity verification, credential rotation scheduler, Docker multi-service deployment, and SQLite databases with WAL mode. 18 tickets completed. 468 tests. The AI posted a blank coffee mug to LinkedIn, failed spectacularly, and we wrote about it honestly.

Sprint 1 built on that foundation with 17 tickets across 5 stories:

  • CI/CD & Test Infrastructure (OAS-090): Service lifecycle management, Docker Compose V3 validation, V2-to-V3 migration verification
  • Quality Gates & Standards (OAS-091): Dev.to skip guards for sensitive data, the Result<T,E> type for explicit error handling, startup validation
  • Infrastructure Health & Monitoring (OAS-092): Database manager, integration test framework, Health.tsx dashboard component
  • Publishing Reliability (OAS-076): Dev.to API verification, blog format compliance, publish-repair pipeline
  • Sprint 1 Retrospective (OAS-074): Work log, persona context review, turn-based ceremony with 11 AI personas, summary and decisions, blog post

Test count climbed from 468 to 925+. Zero blocked items across both sprints.

What the Platform Does Today

The ORCHESTRATE Marketing Platform V2 is a 102-tool MCP server managing content across 4 LinkedIn pages (ORCHESTRATE Method, Run On Rhythm, LEVEL UP, I am HITL). The full capability set:

  • LinkedIn: Post lifecycle, scheduling, analytics, engagement tracking, comment moderation, resharing, approval queues
  • Printify: Product creation, mockup generation, storefront publishing, order tracking
  • Reddit: Search, post, comment, vote across subreddits
  • Dev.to: Article CRUD, search, publishing pipeline with format validation
  • Replicate: AI image generation for visuals and product designs
  • KDP Sales: Amazon book sales import, royalty tracking, marketplace analytics
  • UTM/Attribution: Tracked short links, click recording, post-to-sale correlation
  • Content Calendar: AI-generated weekly plans following 40/20/20/20 content mix
  • A/B Testing: Split tests between post variants with auto-measurement
  • Audience Segmentation: Auto-tagging (Champions, Regulars, Engaged, New) based on interaction frequency
  • Competitor Monitoring: Cross-platform content discovery and relevance scoring
  • Performance Learning: Hook analysis, posting time optimization, hashtag effectiveness with confidence scores
  • Newsletter: Mailchimp-connected campaigns with auto-generated content

All of this runs in a single Docker container: Express API server, React UI, SQLite databases, MCP server, and a built-in scheduler. One docker compose up -d --build and the entire platform is running.

What Failed and What We Learned

The honest failures:

  1. Duplicated test fixtures. The same V2 test data (pages, posts, activity lines, API key patterns) is copy-pasted across 3+ test files. A change to fixture format means updating every copy. This is real technical debt that slows down every new test file.

  2. Inconsistent error handling. Some services use try-catch, others use the new Result<T,E> type. The boundary between the two patterns is implicit — you have to read the code to know which pattern a service uses. No documentation, no lint rule, no convention entry.

  3. No migration framework. Database schema changes are manual. There’s no version tracking, no forward-only migration chain, no way to know which schema version a given database is at. This blocks any multi-environment deployment.

  4. Startup validator is a black box. It runs checks and logs pass/fail, but the events aren’t structured. The health dashboard can’t consume them. There’s no auto-refresh, no way to see when the last check ran, no pause/resume.

  5. String-concatenated paths. Windows path separators broke tests that used 'data/' + filename instead of path.join('data', filename). We found and fixed instances in Sprint 1, but there’s no lint rule preventing new ones.

  6. The AI said “last week” when it meant “earlier today.” Our Performance Learning Memory engine was being built in the same session where the AI demonstrated exactly why it was needed. We wrote about that too.

The lessons that stuck:

  • DD TDD ceremony produces auditable evidence even for tickets where no code change is needed. The process works for “verify and document” just as well as “build and test.”
  • Preserving disagreements verbatim in retro ceremonies leads to better-scoped decisions than forced consensus.
  • The MCP enforcement model (block violations mechanically, don’t warn) eliminates an entire class of “we meant to follow the process” failures.

Sprint 2: Five Retro Decisions

The Sprint 1 retrospective produced five decisions that define Sprint 2’s scope. Each emerged from a specific failure or friction point observed during delivery.

D1: Shared Utilities (HIGH)

Owner: Tess Ter (QA Engineer)

Consolidate duplicated test fixtures into shared modules:

  • tests/utils/devto-test-utils.ts — shared rate-limit handling for Dev.to API tests
  • tests/fixtures/sensitive-patterns.ts — API key format samples used across skip-guard, format validation, and integration tests

Why: Three test files currently copy-paste the same fixture data. When we added underscore detection to API key patterns (a bug TDD caught in Sprint 1), we had to update the same data in multiple places. Shared fixtures mean one update, zero drift.

D2: Result Type Migration (MEDIUM)

Owner: Archi Tect (Principal Solution Architect)

Convert internal services from try-catch to Result<T,E>:

  • database-manager.ts → Result pattern
  • startup-validator.ts → Result pattern
  • HTTP-boundary services (devto-api.ts) stay with try-catch

Why: The Result<T,E> type introduced in Sprint 1 (OAS-091) makes error handling explicit — callers must check isOk() or isErr() before accessing values. But only new services use it. Migrating the two most critical internal services creates a clear boundary: internal = Result, HTTP = try-catch. An ADR will document this boundary.

D3: Migration Framework (HIGH)

Owner: Query Quinn (Database Administrator)

Forward-only numbered SQL migrations with version tracking:

  • Migration files: 001_initial.sql, 002_add_index.sql, etc.
  • Migration table tracking which versions have been applied
  • ADR required before implementation

Why: Without migrations, schema changes are manual and untracked. You can’t deploy to a second environment without manually recreating every schema change. You can’t roll forward reliably. This blocks any path to multi-environment or CI-driven deployments.

D4: Structured Observability (MEDIUM)

Owner: Pip Line (DevOps) + React Ive (Frontend)

Connect the startup validator to the health dashboard:

  • Startup validator emits structured JSON events (not just log lines)
  • Health dashboard auto-refreshes on a configurable interval
  • Last-updated indicator shows when data was last fetched
  • Pause/resume toggle for auto-refresh

Why: The health dashboard (Health.tsx, built in Sprint 1) and the startup validator exist but don’t talk to each other. The dashboard shows static data. The validator logs to console. Connecting them creates the observability loop that operators need.

D5: Path Convention (LOW)

Owner: Api Endor (Backend Developer)

Enforce path.join() / path.resolve() over string concatenation:

  • Lint rule or convention entry flagging string-concatenated paths
  • Audit existing codebase for violations

Why: Windows path separators () broke tests in Sprint 1. The fix was path.join(), but nothing prevents the next developer (human or AI) from writing dir + '/' + file again. A convention with enforcement prevents regression.

How We’ll Execute

Every ticket follows the DD TDD ceremony:

  1. Recall memory from previous tickets
  2. Update documentation describing intended behavior
  3. Bind documents to the ticket
  4. Write failing tests (RED) with evidence
  5. Implement to pass (GREEN) with evidence
  6. Refactor with evidence
  7. Validate all tests pass with full output
  8. Store lessons learned

Each phase requires a logged comment with real evidence — test names, assertion details, failure output, implementation summaries. The MCP server blocks phase transitions without adequate comments. No shortcuts.

Commits follow [TICKET-ID] description format. Push happens at sprint close, not per ticket.

What’s After Sprint 2

The V3 inception is complete. Eight sprints are planned covering:

  • Content sourcing with journalist-grade provenance
  • Audio engine with multi-voice TTS
  • Media orchestration engine
  • YouTube video pipeline
  • Podcast production and distribution
  • Quality gates and human experience
  • UI integration and unified dashboard

Sprint 2 is the bridge — cleaning up Sprint 0-1 technical debt so the V3 features have a solid foundation to build on.

Provenance

Field Value
Sprint Sprint 2 (pre-execution)
Author Content Curator persona, Claude Opus 4.6
Date 2026-03-28
Source: Sprint 0 metrics OAS-072-T1 work log: 18 tickets, 468 tests
Source: Sprint 1 metrics OAS-074-T1 work log: 17 tickets, 925+ tests
Source: D1-D5 decisions OAS-074-T4 retrospective summary
Source: Platform tool count V2 manifest: 102 MCP tools
Source: LinkedIn pages 4 active: ORCHESTRATE Method, Run On Rhythm, LEVEL UP, I am HITL
Temporal claims “Sprint 0” and “Sprint 1” refer to completed sprints as of 2026-03-28
Data sensitivity Checked — no API keys, credentials, or PII in post

This post is part of a series documenting the ORCHESTRATE Marketing Platform build. All development is AI-agent-driven with full traceability through the ORCHESTRATE Agile MCP framework.

Books on the ORCHESTRATE method: The ORCHESTRATE Method | ORCHESTRATE for AI Development | Platform: iamhitl.com

Part 2: what changed when I stopped treating my multi-agent system as an idea and started running it for real

In the first part, I explained why I ended up building a multi-agent flow instead of continuing to push everything into a single conversation. The idea still made sense: separating responsibilities, using different models depending on the phase, and keeping human approval before implementation gave me more order, better cost control, and less noise.

But at that stage I was still solving the conceptual problem.

This second stage was different. It was no longer about defending the idea, but about actually running it. And that was where the problems appeared that do not show up in a diagram or in a strong narrative: permissions, AI systems that do not behave the same way, processes that need a real interactive terminal, configuration that ages badly, and orchestration decisions that sound good on paper but do not hold up in practice.

The biggest change: I stopped thinking in terms of a pipeline and started thinking in terms of a runtime

I think the best way to explain this evolution is this: agentflow stopped being just a way to organize prompts, files, and steps, and started becoming an explicit runtime.

That changed the way I saw it.

Before, the configuration described more of what had to be generated. Now it describes how each role runs: which provider it uses, which model, which effort level, which sandbox, and which prompt governs it. It is no longer just a tool to assemble a flow, but a base for running real roles with more control.

That shift matters because it made me see something that was not fully clear before: separating roles well is not enough. You also need the runtime that makes that separation viable.

Real execution was what showed where the gap actually was

The clearest finding of this stage was simple and brutal: the agents could not write files autonomously.

Not because the design was missing. The sandbox field already existed and the intent was correct. The problem was more uncomfortable: the Claude adapter was not translating that intent into the real CLI flags. So the system ran, but it kept getting blocked asking for permission on every write.

That was one of those moments that forces you to land the idea in reality. Because that is when you understand that a system running and a system working are not the same thing.

The fix was direct, but the lesson mattered more than the fix. For Claude Code I had to translate workspace-write into --dangerously-skip-permissions and read-only into --permission-mode plan. Codex already handled that side more cleanly with --sandbox workspace-write. OpenCode, on the other hand, still has a more structural limitation because its CLI does not expose an equivalent flag.

You do not discover that problem by refining prompts. You discover it by running the system.

It also became clear that orchestration is not the same as delegation

Another thing this stage made very clear was that the orchestrator I had in mind was still not closing the last mile well enough.

In theory, agentflow run already existed and already had sequencing logic. But in practice, when Claude Code, Codex, or OpenCode were participating in a real session, that command was not enough. The bootstrap skills were too shallow. They mostly delegated and stopped there. They did not provide enough context to decide when to stop, which steps to run, how to handle the review loop, or when to ask for human approval.

That was when something became obvious that now feels self-evident: there is no single correct mode of orchestration.

CLI mode makes sense for automation, CI, and deterministic execution. But an interactive session needs something else. It needs the agent to have judgment to classify the task, present a plan, wait for approval before implementing, and decide how to move forward based on context. Trying to force the same mechanism to work both for automation and CI and for an interactive session with judgment and human approval created more friction than it removed.

Not every task deserves the full pipeline

Another improvement that feels genuinely important in this stage was finally grounding the classifier.

In part 1, the intuition was already there: not every task should cost the same. But it was still more of a thesis than a real system capability.

Now there is a role that classifies complexity as small, medium, or large, and that changes the flow. A small change does not need to go through the full ceremony. A larger task does justify the complete pipeline, a review loop, and possible model adjustments. And if a project comes from an older configuration and does not yet have that role, the system does not break: it falls back to a heuristic path and keeps running.

I like this because it moves optimization to the right place. Not after spending time and tokens, but before.

Put more simply: it makes no sense to treat a small bug like a complex migration.

Providers are not interchangeable

This stage also helped me let go of a simplification that is tempting in the abstract: thinking that all AI providers are more or less the same.

They are not.

When I talk about an AI provider, I mean the external system that executes a specific task inside the flow. It can be Claude, Codex, or something else. It is basically the service I delegate work to in one phase of the process.

And once I pushed this into real execution, it became clear that these providers do not behave in the same way. Permissions change, integration styles change, process handling changes, and even the way they expect to be run changes.

In some cases, it is also not enough to launch a command and wait for a response. Some tools need to run inside a real interactive terminal, as if they were opened directly in the console. That is usually called a TTY, but in plain language it means this: the tool needs a real terminal to work properly.

That is what pushed me toward different execution strategies depending on the provider. For some cases, a pipe-based execution worked fine. For Codex, I ended up needing a real PTY with node-pty, because its interface can fail or hang if it does not run in a genuinely interactive terminal.

It sounds like a minor detail, but it is not. Because working with agents is not only about working with text. It is also about processes, permissions, terminals, and real errors. And if that is not designed well, the whole system feels fragile even if the core idea is strong.

Several useful improvements were not flashy, but they were necessary

There were also less visible improvements that mattered more than they seem.

One was stopping the dependency on a rigid testRunner field in the config. That kind of field ages badly. You change the project, change the runner, or change the stack, and you end up carrying stale instructions. It felt much better to let the tester detect that from the project itself when the field is not defined.

These are not flashy changes, but they are the kind that make a tool stop feeling rigid.

Not everything is closed yet, but these are the right problems to have

I do not want to describe this stage as if everything were already perfect, because that would not be true.

There are still open gaps. The test suite passes, but it still does not cover the runtime-first contract deeply enough, especially around real adapter execution, agent run, and the classifier. The documentation is already much more aligned with the current runtime, but OpenCode still has a real sandbox limitation that does not depend only on agentflow.

But honestly, those already feel like healthy problems.

Because I am no longer debating whether the idea makes sense. I am no longer in the phase of defending the thesis. I am now in the phase of closing concrete gaps: compatibility, documentation, robustness, and execution consistency.

And I strongly prefer being there.

What part 2 really left me with

If part 1 was about why a multi-agent system made more sense than one giant conversation, part 2 is about something else: what happens when that idea leaves the page and meets reality.

That was where the real gaps showed up: permissions, effective orchestration, complexity management, providers that do not behave the same, processes, fragile defaults, and traces.

The original idea did not collapse. If anything, it came out stronger.

But now I see it more completely: a multi-agent architecture is not ready just because it looks good in the design. It is ready when it can actually run without breaking on basic things.

Closing

The first version taught me how to separate responsibilities.

This second stage forced me to build the runtime that makes that separation viable.

And real execution ended up teaching me the most important thing: between this runs and this works the way it should, there is a large distance. That distance is not closed with more theory. It is closed by running the system, observing where it fails, and correcting it with concrete changes.

That, for me, is what this second part is really about.

External resources

How to Add Role-Based Access Control to Next.js 16 with Auth.js v5

Most SaaS apps start simple: one user type, one set of features. Then a customer asks for a team plan, an admin dashboard, or a read-only guest mode — and suddenly you need roles.

Role-Based Access Control (RBAC) is how you handle this cleanly. In this guide we’ll add it to a Next.js 16 app using Auth.js v5, Prisma, and TypeScript — with real code you can drop in today.

What We’re Building

Three roles: USER, ADMIN, VIEWER. We’ll:

  1. Store roles in the database
  2. Inject them into the Auth.js session
  3. Protect pages via middleware
  4. Protect API routes with a helper
  5. Conditionally render UI based on role

Step 1: Add the Role to Your Prisma Schema

// prisma/schema.prisma

enum UserRole {
  USER
  ADMIN
  VIEWER
}

model User {
  id            String    @id @default(cuid())
  email         String    @unique
  name          String?
  role          UserRole  @default(USER)
  emailVerified DateTime?
  image         String?
  accounts      Account[]
  sessions      Session[]
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
}

Run the migration:

npx prisma migrate dev --name add-user-role

Step 2: Extend the Auth.js Session

By default Auth.js doesn’t include custom fields in the session. We need to tell it to.

// auth.ts
import NextAuth from "next-auth"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"
import type { UserRole } from "@prisma/client"

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [/* your providers */],
  callbacks: {
    async session({ session, user }) {
      // Attach role to the session token
      session.user.role = user.role as UserRole
      return session
    },
  },
})

Then extend the TypeScript types so your editor knows about it:

// types/next-auth.d.ts
import type { UserRole } from "@prisma/client"

declare module "next-auth" {
  interface User {
    role: UserRole
  }
  interface Session {
    user: {
      id: string
      role: UserRole
    } & DefaultSession["user"]
  }
}

Step 3: Protect Pages with Middleware

Next.js middleware runs at the edge before a page renders — perfect for role gating.

// middleware.ts
import { auth } from "@/auth"
import { NextResponse } from "next/server"

export default auth((req) => {
  const { pathname } = req.nextUrl
  const session = req.auth

  // Unauthenticated: redirect to login
  if (!session && pathname.startsWith("/dashboard")) {
    return NextResponse.redirect(new URL("/login", req.url))
  }

  // Admin-only routes
  if (pathname.startsWith("/dashboard/admin")) {
    if (session?.user.role !== "ADMIN") {
      return NextResponse.redirect(new URL("/dashboard", req.url))
    }
  }

  return NextResponse.next()
})

export const config = {
  matcher: ["/dashboard/:path*"],
}

This is a hard gate — non-admins can’t even load the page.

Step 4: Protect API Routes

For API endpoints, use a helper that reads the session and checks role:

// lib/auth-guard.ts
import { auth } from "@/auth"
import type { UserRole } from "@prisma/client"

export async function requireRole(
  requiredRole: UserRole | UserRole[]
): Promise<void> {
  const session = await auth()

  if (!session?.user) {
    throw new Response("Unauthorized", { status: 401 })
  }

  const allowed = Array.isArray(requiredRole)
    ? requiredRole.includes(session.user.role)
    : session.user.role === requiredRole

  if (!allowed) {
    throw new Response("Forbidden", { status: 403 })
  }
}

Use it in a route handler:

// app/api/admin/users/route.ts
import { NextResponse } from "next/server"
import { requireRole } from "@/lib/auth-guard"
import { prisma } from "@/lib/prisma"

export async function GET() {
  try {
    await requireRole("ADMIN")
  } catch (res) {
    return res
  }

  const users = await prisma.user.findMany({
    select: { id: true, email: true, name: true, role: true },
  })

  return NextResponse.json(users)
}

Step 5: Role-Conditional UI

In Server Components, read the session directly:

// app/dashboard/layout.tsx
import { auth } from "@/auth"

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const session = await auth()
  const isAdmin = session?.user.role === "ADMIN"

  return (
    <div className="flex">
      <nav>
        <a href="/dashboard">Home</a>
        {isAdmin && <a href="/dashboard/admin">Admin Panel</a>}
      </nav>
      <main>{children}</main>
    </div>
  )
}

In Client Components, use the useSession hook:

"use client"
import { useSession } from "next-auth/react"

export function AdminButton() {
  const { data: session } = useSession()

  if (session?.user.role !== "ADMIN") return null

  return <button>Admin Action</button>
}

Note: Client-side role checks are for UX only. Always enforce roles server-side in middleware or API routes — never trust the client.

Bonus: Promote a User to Admin

A quick Prisma script to promote yourself during development:

// scripts/promote-admin.ts
import { prisma } from "@/lib/prisma"

async function main() {
  await prisma.user.update({
    where: { email: "[email protected]" },
    data: { role: "ADMIN" },
  })
  console.log("Done.")
}

main()
  .catch(console.error)
  .finally(() => prisma.$disconnect())

Don’t Want to Wire All This Up Yourself?

RBAC, Auth.js v5, Prisma schema, Stripe subscriptions, and an AI chat layer — it’s a lot of boilerplate to assemble from scratch.

LaunchKit ships all of this pre-configured as a Next.js 16 starter kit:

  • ✅ Auth.js v5 with role support baked in
  • ✅ Prisma schema with Users, Subscriptions, AI Chat
  • ✅ Admin dashboard with role-gated routes
  • ✅ Stripe billing + webhook handler
  • ✅ Dark mode, Tailwind CSS v4, TypeScript strict

$49 one-time. No subscription. Yours forever.

→ Get LaunchKit

Skip the boilerplate. Ship the thing.