Why Most AI Cold Emails Go to Spam (And How to Fix It)

Most AI cold email tools generate the same patterns. The same subject line structures, the same casual tone, the same call-to-action placement. And guess what? Spam filters see it coming from a mile away.

I built 99 Agents (a tool that scores cold email sequences for deliverability), and I’ve seen this play out thousands of times: technically well-written emails landing in spam folders because they feel like they came from an AI.

Here’s what actually works.

1. Structural Randomization: Break the Pattern

AI emails follow predictable rhythms. They have consistent sentence lengths, predictable paragraphs, and templated flows. Spam filters use pattern matching—if your email structure matches what they’ve seen 10,000 times before, you’re flagged.

What to do: Vary sentence length deliberately. Write one word sentences. Then longer ones. Mix short paragraphs with walls of text. The goal isn’t readability—it’s authenticity. Real humans write messy. Embrace the mess.

2. Avoid AI-Pattern Triggers

Certain phrases are red flags. “Hope this email finds you well.” (instant spam score bump). “I’ve been following your work.” (unless you’re specific, it reads generic). Exclamation marks in clusters. Perfect grammar. No typos, ever.

What to do: Include a real observation about their work. Name a specific article, feature, or milestone. Make one small grammatical choice that feels intentional but imperfect—a dash where a comma should be, or starting a sentence with “And” or “But.” Real founders do this. Filters expect it.

3. Subject Line Randomization

The most engineered subject lines fail. “[Name], quick question” or “Following up: [Company]” are template classics that filters have seen millions of times.

What to do: Write subject lines that feel weird in the moment but authentic. Ask a genuine question that might have multiple answers. Reference something specific from their Twitter/work that made you curious. Vary the format—don’t always lead with their name.

4. Timing and Volume

Blasting 500 emails at once is a spam death sentence. Even if each individual email is solid, the volume pattern is a giveaway.

What to do: Stagger sends across days or weeks. Vary send times. Don’t send to an entire list on Tuesday morning—that’s a bot pattern. Mix in real replies to people who respond, breaking the automation signal.

5. Breaking Brevity Myths

The advice “keep it under 150 words” is wrong. A perfectly short email can be more suspicious than a longer one because it’s so clearly templated.

What to do: Write naturally. Sometimes that’s short. Sometimes it’s longer with a real story. The best cold emails I’ve seen are 200-400 words with a specific ask buried in the middle—not at the bottom, not in a P.S.

How I Score This Stuff

When I built the Sequence Health Score for 99 Agents, we looked at thousands of emails that actually got replies vs. ones that hit spam. The pattern was clear: the ones that worked didn’t look like they came from a “cold email tool.”

They had personality. They broke their own rules. They felt like one founder emailing another, not a sequence.

The score looks at:

  • Structural variation (sentence length, paragraph rhythm)
  • Pattern triggers (how many template phrases you’re using)
  • Personalization depth (not just {first_name}, but real context)
  • Send behavior (volume, timing, list freshness)

Most AI tools max out at 2-3 of these. The ones that work nail all of them.

The Real Secret

Spam filters aren’t stupid. They’re trained on millions of real vs. spam emails. They can spot AI-generated text because AI text is too consistent. It’s too polished. It makes too much sense.

The best cold emails make less sense on the surface—because they’re written by a real person who’s thinking about the recipient, not optimizing for a template.

If you’re sending cold emails and they’re landing in spam, it’s not because you’re too pushy or too casual. It’s probably because the structure screams “automated.”

Randomize it. Make it weird. Make it real. That’s what works.

Check out 99 Agents if you want to score your next sequence: https://nine9agents.polsia.app?utm_source=devto&utm_medium=article&utm_campaign=launch

We’ll tell you exactly where your email is vulnerable and what to fix. No magic bullet—just honest feedback on why it might be hitting spam, and what real founders are doing instead.

Automating Cross-Posting with Attributed Unsplash Cover Images

Cover image: multi-monitor programming workspace

Image by Jakub Żerdzicki on Unsplash.

At most teams, image handling is where “automated posting” quietly becomes manual again.

The text pipeline works. The publishing APIs work. But images end up as an afterthought: no attribution, inconsistent style, weak relevance, or missing cover fields on one platform. This post documents the production workflow we now use to fix that.

The Goal

Publish the same article to Hashnode and Dev.to with:

  • one aligned hero image
  • explicit image attribution close to the image
  • platform-native cover mapping
  • deterministic quality checks before publish

The Image Pipeline We Implemented

  1. Generate article draft and title.
  2. Query Unsplash using article-aware keywords.
  3. Select a landscape image with strong topical fit.
  4. Capture attribution metadata:
    • photographer name
    • photographer profile URL
    • photo page URL
  5. Register Unsplash download tracking for compliance.
  6. Map the same chosen image URL to both platforms:
    • Hashnode coverImageURL
    • Dev.to main_image
  7. Insert attribution line directly beneath the first in-body image.

Why This Works Better

This workflow removes ambiguity. Instead of “find an image later,” every publish run has required image fields and a go/no-go quality gate.

That gives us:

  • visual consistency across platforms
  • proper author attribution on every article
  • better click-through appeal
  • fewer manual fixes after publish

Practical Rules We Enforce

  • No image, no publish (unless explicit override).
  • Attribution is mandatory and near the image.
  • One canonical hero image per article for cross-platform consistency.
  • Image must be public and renderable before API publish.

Final Note

Automation is only “production-ready” when content quality, attribution, and compliance are embedded into the workflow, not bolted on afterward.

This post itself was published through that exact pipeline.

Connect With Me

Krishna Soni — Game Developer, Researcher, Author of The Power of Gaming

LinkedIn: Krishna Soni | Kri Zek

Web: krizek.tech | Altered Brilliance on Google Play

Socials: Happenstance | Instagram @krizekster | Instagram @krizek.tech | Instagram @krizekindia

CDKTF Has Free Terraform in TypeScript — Here’s How to Ditch HCL Forever

Love Terraform but hate HCL? CDKTF lets you write Terraform configs in TypeScript, Python, Java, C#, or Go.

What is CDKTF?

CDK for Terraform (CDKTF) is an open-source project by HashiCorp that lets you define Terraform infrastructure using programming languages instead of HCL.

Quick Start

npm install -g cdktf-cli
cdktf init --template=typescript --providers=aws

Your First CDKTF Stack

import { Construct } from "constructs";
import { App, TerraformStack, TerraformOutput } from "cdktf";
import { AwsProvider } from "@cdktf/provider-aws/lib/provider";
import { S3Bucket } from "@cdktf/provider-aws/lib/s3-bucket";
import { Instance } from "@cdktf/provider-aws/lib/instance";

class MyStack extends TerraformStack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    new AwsProvider(this, "aws", { region: "us-east-1" });

    // S3 Bucket
    const bucket = new S3Bucket(this, "app-bucket", {
      bucket: "my-app-assets-2026",
      tags: { Environment: "production" },
    });

    // EC2 Instance
    const server = new Instance(this, "web-server", {
      ami: "ami-0c02fb55956c7d316",
      instanceType: "t3.micro",
      tags: { Name: "WebServer" },
    });

    new TerraformOutput(this, "bucket-name", { value: bucket.bucket });
    new TerraformOutput(this, "server-ip", { value: server.publicIp });
  }
}

const app = new App();
new MyStack(app, "my-infra");
app.synth();
cdktf deploy

Loops and Conditions (Why CDKTF Wins)

// Create 3 identical servers with a loop
const serverNames = ["web-1", "web-2", "web-3"];
serverNames.forEach(name => {
  new Instance(this, name, {
    ami: "ami-0c02fb55956c7d316",
    instanceType: "t3.micro",
    tags: { Name: name },
  });
});

// Conditional resources
if (isProduction) {
  new Instance(this, "monitoring", {
    instanceType: "t3.large",
    // ...
  });
}

In HCL you would need count, for_each, and conditional expressions. In CDKTF — just use TypeScript.

Reusable Constructs

class WebApp extends Construct {
  public readonly url: string;

  constructor(scope: Construct, id: string, props: { domain: string; size: string }) {
    super(scope, id);

    const bucket = new S3Bucket(this, "assets", {
      bucket: `${props.domain}-assets`,
    });

    const server = new Instance(this, "server", {
      instanceType: props.size,
      ami: "ami-0c02fb55956c7d316",
    });

    this.url = server.publicDns;
  }
}

// Use it
const app1 = new WebApp(this, "app1", { domain: "example.com", size: "t3.micro" });
const app2 = new WebApp(this, "app2", { domain: "staging.example.com", size: "t3.small" });

CDKTF vs Pulumi vs HCL Terraform

Feature CDKTF Pulumi HCL Terraform
Languages TS, Python, Go, Java, C# TS, Python, Go, C# HCL
State Backend Terraform (S3, Cloud) Pulumi Cloud, S3 S3, Cloud
Provider Ecosystem All Terraform providers Most TF providers All native
Existing TF Code Import directly Convert needed Native
Testing Jest/Vitest Built-in Terratest

Key Advantage: Full Terraform Ecosystem

CDKTF generates standard Terraform JSON. This means every Terraform provider, every module, every backend — it all works. You get TypeScript DX with Terraform reliability.

Need managed infrastructure for data extraction? Check out my Apify actors — no infrastructure management needed. For custom solutions, email [email protected].

Ollama & LangChain.js: Build Local, Powerful AI Apps

I created a new website: Free Access to the 8 Volumes on Typescript & AI Masterclass, no registration required. Choose Volume and chapter on the menu on the left. 160 Chapters and hundreds of quizzes at the end of chapters.

Bridging Local Intelligence with Structured Workflows

The integration of Ollama with LangChain.js represents a significant shift in how we build intelligent applications. It moves us away from relying solely on cloud-based LLM APIs and towards a modular, locally-hosted ecosystem. This approach empowers developers to create more private, performant, and deterministic AI solutions. This post will dive into the core concepts, analogies, and practical code examples to help you understand and implement this powerful combination.

Understanding the Core Concepts: Raw vs. Structured Models

At its heart, the difference lies in how we interact with the Large Language Model (LLM). Direct API integration with Ollama, while functional, is akin to using low-level sockets – sending raw strings and receiving raw strings. This lacks structure, type safety, and the ability to chain operations seamlessly.

LangChain.js acts as the “Express.js” or “Next.js” framework for LLMs, providing the necessary abstraction layer. It wraps raw model calls in standardized interfaces (LLM, Embeddings, Runnable), enabling the creation of deterministic pipelines instead of imperative scripts.

Specifically, we’ll focus on two key components:

  • OllamaLLM (The Reasoner): Handles text generation, taking a prompt and returning a completion. Crucially, it’s a “Runnable” that can be composed into larger chains.
  • OllamaEmbeddings (The Vectorizer): Transforms text into numerical vectors (embeddings) capturing semantic meaning, allowing for similarity comparisons.

The Web Development Analogy: A Full-Stack Application

Think of a LangChain.js application like a modern full-stack web app:

  • Database vs. Embedding Model (OllamaEmbeddings): A database stores data, but raw data is often unstructured. We index it with search engines like Elasticsearch or vector databases like Pinecone for efficient searching. OllamaEmbeddings is the “build process” that generates these indexes (vectors) from your text, ensuring your data stays local.
  • API Route vs. LLM (OllamaLLM): An API route receives requests, processes logic, and returns responses. OllamaLLM is the serverless function or API endpoint, accepting a prompt and generating a response, but within a structured, orchestratable framework.
  • Orchestrator vs. Chain: Tools like Redux or server-side middleware manage data flow in complex web apps. A LangChain Chain is the middleware pipeline, connecting retrieval to generation: Retrieve -> Format Prompt -> Generate -> Parse Output.

Why Integrate Ollama with LangChain.js?

Instead of raw API calls, why wrap Ollama in LangChain.js? The benefits are significant:

  • Latency: Cloud LLMs involve network requests. With OllamaLLM locally, inference happens on the same machine, drastically reducing response times.
  • Privacy: Data never leaves your machine, crucial for handling sensitive information (PII).
  • Structured Output: LangChain’s Output Parsers enforce type safety, preventing crashes from malformed LLM responses.

Diving into the Code: A Basic Example

Let’s build a semantic search engine for a product catalog using Ollama and LangChain.js.

Prerequisites:

  1. Ollama installed and running locally.
  2. nomic-embed-text model pulled: ollama pull nomic-embed-text.
  3. Node.js environment with langchain and @langchain/community installed.
// src/ollama-langchain-basic.ts

import { OllamaEmbeddings } from "@langchain/community/embeddings/ollama";
import { Ollama } from "@langchain/community/llms/ollama";
import { MemoryVectorStore } from "langchain/vectorstores/memory";
import { Document } from "langchain/document";

const embeddings = new OllamaEmbeddings({
  model: "nomic-embed-text",
  baseUrl: "http://localhost:11434",
});

const llm = new Ollama({
  model: "llama3.1",
  baseUrl: "http://localhost:11434",
});

const documents: Document[] = [
  new Document({
    pageContent: "The QuickStart Pro running shoes feature advanced cushioning technology for long-distance runners.",
    metadata: { category: "Footwear", price: 120, id: "prod_001" },
  }),
  // ... more documents
];

async function searchProducts(query: string): Promise<Document[]> {
  const vectorStore = await MemoryVectorStore.fromDocuments(
    documents,
    embeddings
  );
  const searchResults = await vectorStore.similaritySearch(query, 2);
  return searchResults;
}

async function generateResponse(query: string, context: Document[]) {
  const contextText = context
    .map((doc) => `Product: ${doc.pageContent} (Price: $${doc.metadata.price})`)
    .join("n");

  const prompt = `
    User Question: ${query}

    Context Products:
    ${contextText}

    Based on the context above, recommend the best product for the user.
    Be concise and friendly.
  `;
  const response = await llm.invoke(prompt);
  return response;
}

async function main() {
  const userQuery = "I need shoes for running marathons";
  const relevantDocs = await searchProducts(userQuery);
  const aiResponse = await generateResponse(userQuery, relevantDocs);
  console.log("n=== FINAL RESULT ===");
  console.log("Recommended Product:", aiResponse);
}

main();

This example demonstrates the core workflow: embedding the query, performing a similarity search, and generating a response using the retrieved context.

Advanced Application: Semantic Search with pgvector and Ollama

For production environments, consider using pgvector with Supabase for scalable semantic search. This architecture combines the benefits of local LLM inference with a robust vector database. The code would involve:

  1. Server Action: Receiving the user query.
  2. Embedding Generation: Using OllamaEmbeddings to create a vector representation of the query.
  3. pgvector Search: Querying the documents table in PostgreSQL using pgvector to find similar documents.
  4. Context Injection: Passing the retrieved documents to OllamaLLM for a context-aware response.

Performance Optimization & Common Pitfalls

Integrating Ollama with LangChain.js requires attention to performance:

  • Memory Management: Be mindful of the keep_alive parameter in Ollama to free up resources.
  • Concurrency: Node.js is single-threaded. Offload heavy processing using RunnableLambda or asynchronous operations.
  • Token Limits: Local models have smaller context windows. Use splitters and limit retrieved chunks.
  • Ollama Timeouts: Increase timeouts for CPU-heavy models.
  • JSON Parsing: Handle potential errors when parsing LLM output.

Conclusion: The Future of Local AI

By integrating Ollama with LangChain.js, you’re building a local, distributed system that offers privacy, performance, and control. This approach unlocks a new era of AI development, empowering you to create powerful applications entirely on your own infrastructure. Embrace the power of local inference and structured workflows to build the next generation of intelligent applications.

The concepts and code demonstrated here are drawn directly from the comprehensive roadmap laid out in the book The Edge of AI. Local LLMs (Ollama), Transformers.js, WebGPU, and Performance Optimization Amazon Link of the AI with JavaScript & TypeScript Series.
The ebook is also on Leanpub.com: https://leanpub.com/EdgeOfAIJavaScriptTypeScript.

👉 Free Access now to the TypeScript & AI Series on Programming Central, it includes 8 Volumes, 160 Chapters and hundreds of quizzes for every chapter.

How to Integrate Codacy with Bitbucket Pipelines

Codacy screenshot

Why integrate Codacy with Bitbucket

Bitbucket and Atlassian’s tool ecosystem power a significant portion of the world’s software development teams, particularly in enterprises that rely on Jira, Confluence, and the broader Atlassian suite. If your team lives in Bitbucket, integrating Codacy gives you automated code quality analysis on every pull request without leaving your existing workflow.

The combination of Codacy and Bitbucket Pipelines covers everything a quality-focused team needs: static analysis across 49 programming languages, security vulnerability scanning (SAST), secrets detection, duplication checking, and test coverage tracking – all surfaced directly on your Bitbucket pull requests as status checks and inline comments.

This guide covers the end-to-end setup for both Bitbucket Cloud and Bitbucket Data Center users. You will learn how to install the Codacy app, configure your bitbucket-pipelines.yml to upload coverage reports, set up quality gates that can pass or fail your pipelines, configure PR status checks and webhooks, and troubleshoot the most common issues that come up during integration.

If you are new to Codacy altogether, our Codacy review gives a comprehensive overview of what the platform offers before you commit to the integration work.

Codacy screenshot

Bitbucket Cloud vs Bitbucket Data Center

Before walking through the setup, it is worth clarifying the two distinct Bitbucket products and how Codacy supports each of them.

Bitbucket Cloud is Atlassian’s hosted service at bitbucket.org. The Codacy cloud platform at app.codacy.com supports Bitbucket Cloud natively. You install the Codacy app from the Atlassian Marketplace, authenticate with your Bitbucket account, and everything is managed through Codacy’s cloud infrastructure. This is the path covered in most of this guide.

Bitbucket Data Center (formerly Bitbucket Server) is the self-hosted, on-premise version that organizations run on their own infrastructure. Codacy supports Bitbucket Data Center through Codacy Self-Hosted, the on-premise edition of the platform. Self-Hosted is available on Enterprise plans and requires deploying Codacy containers in your own Kubernetes or Docker environment. The configuration steps differ from the cloud approach, and you will need to contact Codacy’s sales team to get started.

If you are on Bitbucket Cloud, continue with the steps below. If you are on Bitbucket Data Center, the conceptual steps are the same but the installation process involves deploying Codacy Self-Hosted on your infrastructure first.

Step 1: Install the Codacy Bitbucket app

The Codacy Bitbucket app handles the webhook integration that triggers analysis on every push and pull request. Installing it is the first step before any pipeline configuration.

Installing from the Atlassian Marketplace

  1. Go to your Bitbucket workspace and click Settings in the left navigation
  2. Under Workspace settings, click Installed apps then Find new apps
  3. Search for “Codacy” in the Atlassian Marketplace
  4. Click Try it free or Get it now on the Codacy listing
  5. Follow the prompts to authorize the app to access your workspace

Alternatively, you can sign in at app.codacy.com using your Bitbucket credentials. Codacy will request Bitbucket OAuth permissions during the sign-in process, which effectively installs the integration.

Adding repositories to Codacy

After installing the app and signing in to Codacy:

  1. Click Add repository in the Codacy dashboard
  2. Select your Bitbucket workspace from the dropdown
  3. Find the repository you want to analyze and click Add
  4. Codacy begins an initial analysis of your default branch

Codacy automatically installs webhooks in your Bitbucket repository during this process. You can verify the webhooks were created by going to your Bitbucket repository settings, clicking Webhooks under Workflow, and checking that a Codacy webhook URL appears in the list.

Repository access levels

Codacy requires read access to your repository code to perform analysis and write access to post PR status checks and comments. When you add a repository, Codacy will prompt you to confirm these permissions. If your Bitbucket workspace uses IP allowlisting, add Codacy’s IP ranges to your allowlist to ensure webhook delivery succeeds.

Step 2: Get your Codacy project API token

The project API token authenticates the Codacy Coverage Reporter when it uploads coverage data from your Bitbucket Pipelines build to the Codacy platform.

To find the token:

  1. Navigate to your repository in the Codacy dashboard
  2. Click Settings in the left sidebar
  3. Select Coverage (or Integrations on some plan levels)
  4. Copy the Project API Token

Once you have the token, store it as a Bitbucket repository variable so your pipeline can use it without hardcoding it in your YAML file:

  1. Go to your Bitbucket repository
  2. Click Repository settings then Repository variables under Pipelines
  3. Add a variable:
    • Name: CODACY_PROJECT_TOKEN
    • Value: Paste your Codacy project token
    • Secured: Check this box to mask the value in logs
  4. Click Add

If you also want to check quality gates from your pipeline, you will need a Codacy account API token as well. Generate one from your Codacy account settings under Access tokens and store it as CODACY_ACCOUNT_TOKEN in your Bitbucket repository variables using the same process.

Step 3: Configure bitbucket-pipelines.yml for coverage upload

The core of the Bitbucket Pipelines integration is adding a coverage upload step to your bitbucket-pipelines.yml file. The Codacy Coverage Reporter is a shell script that you download and run after your tests complete.

Basic pipeline structure

Here is the general pattern that applies to any language:

pipelines:
  default:
    - step:
        name: Build, Test, and Upload Coverage
        script:
          - # Run your build and tests here
          - pipe: atlassian/git-secrets-scan:0.5.1
          - curl -Ls https://coverage.codacy.com/get.sh -o codacy-coverage-reporter.sh
          - bash codacy-coverage-reporter.sh report
              -r <path-to-coverage-report>
              --project-token $CODACY_PROJECT_TOKEN
              --commit-uuid $BITBUCKET_COMMIT

The $BITBUCKET_COMMIT environment variable is provided automatically by Bitbucket Pipelines and contains the full commit SHA. Passing it to the Coverage Reporter with --commit-uuid ensures Codacy associates the coverage data with the correct commit, which is critical for PR-level coverage display.

Java project with Maven and JaCoCo

image: maven:3.9-eclipse-temurin-17

pipelines:
  default:
    - step:
        name: Build and Test
        caches:
          - maven
        script:
          - mvn clean test
    - step:
        name: Upload Coverage to Codacy
        script:
          - curl -Ls https://coverage.codacy.com/get.sh -o codacy-coverage-reporter.sh
          - bash codacy-coverage-reporter.sh report
              -r target/site/jacoco/jacoco.xml
              --project-token $CODACY_PROJECT_TOKEN
              --commit-uuid $BITBUCKET_COMMIT

  pull-requests:
    '**':
      - step:
          name: Build and Test
          caches:
            - maven
          script:
            - mvn clean test
      - step:
          name: Upload Coverage to Codacy
          script:
            - curl -Ls https://coverage.codacy.com/get.sh -o codacy-coverage-reporter.sh
            - bash codacy-coverage-reporter.sh report
                -r target/site/jacoco/jacoco.xml
                --project-token $CODACY_PROJECT_TOKEN
                --commit-uuid $BITBUCKET_COMMIT

The pull-requests section tells Bitbucket Pipelines to run this pipeline whenever a pull request is opened or updated, regardless of the source branch name ('**' matches all branch names). This ensures every PR gets coverage uploaded to Codacy.

JavaScript and TypeScript with npm

image: node:20-alpine

pipelines:
  default:
    - step:
        name: Install and Test
        caches:
          - node
        script:
          - npm ci
          - npm test -- --coverage --coverageReporters=lcov
    - step:
        name: Upload Coverage to Codacy
        script:
          - apk add --no-cache curl bash
          - curl -Ls https://coverage.codacy.com/get.sh -o codacy-coverage-reporter.sh
          - bash codacy-coverage-reporter.sh report
              -r coverage/lcov.info
              --project-token $CODACY_PROJECT_TOKEN
              --commit-uuid $BITBUCKET_COMMIT

  pull-requests:
    '**':
      - step:
          name: Install and Test
          caches:
            - node
          script:
            - npm ci
            - npm test -- --coverage --coverageReporters=lcov
      - step:
          name: Upload Coverage to Codacy
          script:
            - apk add --no-cache curl bash
            - curl -Ls https://coverage.codacy.com/get.sh -o codacy-coverage-reporter.sh
            - bash codacy-coverage-reporter.sh report
                -r coverage/lcov.info
                --project-token $CODACY_PROJECT_TOKEN
                --commit-uuid $BITBUCKET_COMMIT

Note the apk add --no-cache curl bash step – the Alpine-based Node image does not include curl or bash by default, so you need to install them before downloading the Coverage Reporter. If you are using a Debian-based image (e.g., node:20), replace apk with apt-get install -y curl.

Python with pytest

image: python:3.12-slim

pipelines:
  default:
    - step:
        name: Test with Coverage
        script:
          - pip install -r requirements.txt pytest pytest-cov
          - pytest --cov=src --cov-report=xml:coverage.xml
    - step:
        name: Upload Coverage to Codacy
        script:
          - apt-get update && apt-get install -y curl
          - curl -Ls https://coverage.codacy.com/get.sh -o codacy-coverage-reporter.sh
          - bash codacy-coverage-reporter.sh report
              -r coverage.xml
              --project-token $CODACY_PROJECT_TOKEN
              --commit-uuid $BITBUCKET_COMMIT

  pull-requests:
    '**':
      - step:
          name: Test with Coverage
          script:
            - pip install -r requirements.txt pytest pytest-cov
            - pytest --cov=src --cov-report=xml:coverage.xml
      - step:
          name: Upload Coverage to Codacy
          script:
            - apt-get update && apt-get install -y curl
            - curl -Ls https://coverage.codacy.com/get.sh -o codacy-coverage-reporter.sh
            - bash codacy-coverage-reporter.sh report
                -r coverage.xml
                --project-token $CODACY_PROJECT_TOKEN
                --commit-uuid $BITBUCKET_COMMIT

Step 4: PR status checks in Bitbucket

Once Codacy is connected to your Bitbucket repository, it automatically posts a status check to each pull request. The status check appears in the PR’s merge button area and shows whether Codacy analysis passed or found issues.

Understanding Codacy status check states

Codacy posts one of three statuses to Bitbucket for each PR:

  • Successful – The PR passes your configured quality gate thresholds and no blocking issues were found
  • Failed – The PR violates one or more quality gate thresholds (too many new issues, coverage too low, etc.)
  • Pending – Codacy is still processing the analysis or waiting for coverage data

The status check name in Bitbucket will appear as “Codacy Static Analysis” or similar, depending on your Codacy plan and configuration.

Requiring Codacy status checks before merge

To enforce code quality at the PR level, configure Bitbucket to require the Codacy status check to pass before any PR can be merged:

  1. Go to your Bitbucket repository and click Repository settings
  2. Under Workflow, click Branch restrictions
  3. Click Add a branch restriction
  4. Set the branch pattern to match your target branch (e.g., main or master)
  5. Under Merge checks, enable Require minimum number of approvals if desired
  6. Also enable any option for requiring CI/CD status checks to pass
  7. Save the restriction

With this configured, a PR cannot be merged until Codacy’s analysis completes and returns a successful status. This effectively makes Codacy analysis a required gate in your development workflow.

Configuring what Codacy analyzes on PRs

By default, Codacy analyzes new code introduced in the PR and compares it against your quality gate thresholds. You can control this behavior in your Codacy repository settings:

  • New issues gate – Fail if the PR introduces more than N issues (configurable per severity level)
  • Coverage gate – Fail if coverage on new code falls below a threshold
  • Duplication gate – Fail if the PR introduces duplicated code above a threshold

Navigate to your repository in Codacy, click Quality settings, and adjust the thresholds to match your team’s standards.

Step 5: Coverage reporting via Bitbucket Pipelines

Coverage reporting from Bitbucket Pipelines to Codacy is straightforward for most setups, but there are some nuances worth understanding for larger or more complex projects.

Uploading multiple coverage reports

If your project has separate test suites – unit tests and integration tests, for example – or if you run tests in parallel Bitbucket Pipelines steps, you can upload multiple partial coverage reports that Codacy aggregates into a single metric.

pipelines:
  pull-requests:
    '**':
      - parallel:
          - step:
              name: Unit Tests
              script:
                - mvn test -P unit-tests
                - curl -Ls https://coverage.codacy.com/get.sh -o codacy-coverage-reporter.sh
                - bash codacy-coverage-reporter.sh report
                    -r target/site/jacoco-unit/jacoco.xml
                    --partial
                    --project-token $CODACY_PROJECT_TOKEN
                    --commit-uuid $BITBUCKET_COMMIT
          - step:
              name: Integration Tests
              script:
                - mvn verify -P integration-tests
                - curl -Ls https://coverage.codacy.com/get.sh -o codacy-coverage-reporter.sh
                - bash codacy-coverage-reporter.sh report
                    -r target/site/jacoco-integration/jacoco.xml
                    --partial
                    --project-token $CODACY_PROJECT_TOKEN
                    --commit-uuid $BITBUCKET_COMMIT
      - step:
          name: Finalize Coverage
          script:
            - curl -Ls https://coverage.codacy.com/get.sh -o codacy-coverage-reporter.sh
            - bash codacy-coverage-reporter.sh final
                --project-token $CODACY_PROJECT_TOKEN
                --commit-uuid $BITBUCKET_COMMIT

The --partial flag tells Codacy to expect more coverage data before aggregating. The final command in the downstream step signals that all partial reports have been uploaded. Without the final command, Codacy keeps waiting and the coverage dashboard never updates.

Coverage for monorepos

For monorepos with multiple components in subdirectories, you can upload coverage from each component using separate reporter invocations with the --partial flag, then finalize once all uploads are complete. Keep in mind that the Codacy project token is per-repository, so all components in the same Bitbucket repository share the same token and coverage metrics.

If different monorepo components are treated as separate Codacy repositories, each has its own token and you upload coverage to the corresponding project independently.

Step 6: Quality gates with Bitbucket Pipelines

Quality gates let you fail a Bitbucket Pipelines build based on whether the code meets your configured thresholds. This gives you pipeline-level enforcement in addition to PR status checks.

Setting up quality gate thresholds in Codacy

Before querying quality gates from your pipeline, configure the thresholds:

  1. Open your repository in Codacy
  2. Click Quality settings in the sidebar
  3. Set thresholds for the metrics relevant to your team:
Metric Example Threshold Effect
New issues (critical) 0 allowed Fail on any critical issue
New issues (total) 5 allowed Fail if more than 5 new issues
Coverage on new code 60% minimum Fail if new code is under 60% covered
Coverage variation -2% maximum Fail if overall coverage drops
Duplication 5% maximum Fail on excessive duplication

Querying quality gates from bitbucket-pipelines.yml

After coverage is uploaded and analysis is triggered, query the Codacy API to check the quality gate status:

- step:
    name: Check Codacy Quality Gate
    script:
      - |
        COMMIT_SHA=$BITBUCKET_COMMIT
        ORG="your-org"
        REPO="your-repo"
        PROVIDER="bb"
        MAX_ATTEMPTS=20
        SLEEP_SECONDS=15
        PASSED=false

        for i in $(seq 1 $MAX_ATTEMPTS); do
          RESPONSE=$(curl -s -w "n%{http_code}" 
            "https://app.codacy.com/api/v3/analysis/organizations/${PROVIDER}/${ORG}/repositories/${REPO}/commits/${COMMIT_SHA}/quality" 
            -H "api-token: $CODACY_ACCOUNT_TOKEN" 
            -H "Accept: application/json")

          HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
          BODY=$(echo "$RESPONSE" | head -n -1)

          if [ "$HTTP_CODE" = "200" ]; then
            IS_UP=$(echo "$BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('data',{}).get('isUpToStandards','null'))")

            if [ "$IS_UP" = "True" ]; then
              echo "Codacy quality gate PASSED."
              PASSED=true
              break
            elif [ "$IS_UP" = "False" ]; then
              echo "Codacy quality gate FAILED. Check the Codacy dashboard for details."
              exit 1
            fi
          fi

          echo "Waiting for Codacy analysis (attempt $i of $MAX_ATTEMPTS)..."
          sleep $SLEEP_SECONDS
        done

        if [ "$PASSED" = "false" ]; then
          echo "WARNING: Quality gate check timed out. Proceeding."
        fi

The provider code for Bitbucket is bb. For GitHub it is gh and for GitLab it is gl. Replace your-org with your Bitbucket workspace slug and your-repo with the repository slug as they appear in your Bitbucket URL.

The polling loop waits up to 5 minutes (20 attempts x 15 seconds) for Codacy to complete analysis before timing out. Adjust these values based on your repository size and typical Codacy analysis duration.

Using Python to parse the API response

The shell-based JSON parsing above uses Python, which is available in most CI images. If your pipeline image does not have Python, you can use jq instead:

IS_UP=$(echo "$BODY" | jq -r '.data.isUpToStandards')

Or install jq if needed:

apt-get install -y jq

Step 7: Webhook setup and configuration

Webhooks are the mechanism that tells Codacy when to start analyzing your code. For most teams, the Codacy Bitbucket app configures webhooks automatically. This section covers manual webhook setup for cases where automatic configuration does not work, and how to verify your webhooks are functioning.

Verifying automatic webhook installation

After connecting your repository to Codacy, verify the webhook exists:

  1. Go to your Bitbucket repository
  2. Click Repository settings then Webhooks under Workflow
  3. You should see a webhook with a URL pointing to app.codacy.com or webhooks.codacy.com
  4. Check that the webhook triggers are set to at least Repository push and Pull request events

If the webhook is missing, you can try removing and re-adding your repository in Codacy to trigger automatic webhook re-installation.

Manual webhook configuration

If you need to configure webhooks manually – for example in a Bitbucket Data Center environment or when automatic installation fails:

  1. Go to your Bitbucket repository settings and click Webhooks
  2. Click Add webhook
  3. Fill in the webhook details:
    • Title: Codacy
    • URL: Copy the webhook URL from your Codacy repository settings under Integrations
    • Status: Active
    • SSL/TLS: Verify SSL (keep enabled unless you have certificate issues)
  4. Under Triggers, select:
    • Repository: Push
    • Pull Request: Created, Updated, Approved, Comment created
  5. Click Save

After adding the webhook, trigger it by pushing a commit or creating a test PR. Check the webhook delivery history in Bitbucket to confirm Codacy received the payload and responded with a 200 status code.

Webhook delivery failures

If Codacy is not triggering on your pushes or PRs, check the webhook delivery history. Common causes of delivery failures include:

  • Network restrictions – If your Bitbucket instance (Data Center) cannot reach Codacy’s servers, webhook deliveries will fail. Check firewall rules and ensure outbound HTTPS to Codacy’s domain is allowed.
  • Incorrect webhook URL – Regenerate the webhook URL from Codacy’s settings and update the Bitbucket webhook accordingly.
  • IP allowlisting – If Bitbucket Cloud’s webhook delivery IPs are blocked on your end, add Atlassian’s webhook IP ranges to your allowlist.

Step 8: Complete bitbucket-pipelines.yml example

Here is a complete, production-ready bitbucket-pipelines.yml that combines build, test, coverage upload, and quality gate checking for a Java project. Adapt the language-specific parts to match your stack.

image: maven:3.9-eclipse-temurin-17

definitions:
  caches:
    maven: ~/.m2/repository
  steps:
    - step: &build-and-test
        name: Build and Test
        caches:
          - maven
        script:
          - mvn clean test
        artifacts:
          - target/site/jacoco/**
          - target/surefire-reports/**

    - step: &upload-coverage
        name: Upload Coverage to Codacy
        script:
          - curl -Ls https://coverage.codacy.com/get.sh -o codacy-coverage-reporter.sh
          - bash codacy-coverage-reporter.sh report
              -r target/site/jacoco/jacoco.xml
              --project-token $CODACY_PROJECT_TOKEN
              --commit-uuid $BITBUCKET_COMMIT

    - step: &quality-gate
        name: Check Codacy Quality Gate
        script:
          - |
            COMMIT_SHA=$BITBUCKET_COMMIT
            ORG="your-workspace"
            REPO="your-repo"
            MAX_ATTEMPTS=20
            SLEEP_SECONDS=15
            PASSED=false

            for i in $(seq 1 $MAX_ATTEMPTS); do
              RESPONSE=$(curl -s -w "n%{http_code}" 
                "https://app.codacy.com/api/v3/analysis/organizations/bb/${ORG}/repositories/${REPO}/commits/${COMMIT_SHA}/quality" 
                -H "api-token: $CODACY_ACCOUNT_TOKEN" 
                -H "Accept: application/json")

              HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
              BODY=$(echo "$RESPONSE" | head -n -1)

              if [ "$HTTP_CODE" = "200" ]; then
                IS_UP=$(echo "$BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('data',{}).get('isUpToStandards','null'))")

                if [ "$IS_UP" = "True" ]; then
                  echo "Codacy quality gate PASSED."
                  PASSED=true
                  break
                elif [ "$IS_UP" = "False" ]; then
                  echo "Codacy quality gate FAILED."
                  exit 1
                fi
              fi

              echo "Waiting for Codacy analysis (attempt $i of $MAX_ATTEMPTS)..."
              sleep $SLEEP_SECONDS
            done

            if [ "$PASSED" = "false" ]; then
              echo "Quality gate timed out. Check Codacy dashboard manually."
            fi

pipelines:
  default:
    - step: *build-and-test
    - step: *upload-coverage

  pull-requests:
    '**':
      - step: *build-and-test
      - step: *upload-coverage
      - step: *quality-gate

  branches:
    main:
      - step: *build-and-test
      - step: *upload-coverage
      - step: *quality-gate
    develop:
      - step: *build-and-test
      - step: *upload-coverage
      - step: *quality-gate

The YAML anchors (&build-and-test, *build-and-test) let you define pipeline steps once and reuse them across multiple pipeline configurations without duplication. The artifacts section in the build step ensures the JaCoCo coverage report file is available to the upload step even though they run as separate pipeline steps.

Codacy Bitbucket vs other CI integrations

Codacy’s Bitbucket integration compares favorably to its other CI integrations in terms of native support. The Bitbucket app is a first-class integration that provides automatic webhook setup, PR status checks, inline code comments, and dashboard integration out of the box.

For teams using GitHub, the experience is similar – see our Codacy GitHub integration guide for a side-by-side comparison. For GitLab users, check out Codacy GitLab integration.

The main difference between Bitbucket and GitHub/GitLab integrations is that Codacy’s GitHub App tends to get feature updates first, since GitHub represents the largest share of Codacy’s user base. That said, Bitbucket integration is mature and well-supported, and teams using Bitbucket Cloud should not experience meaningful feature gaps.

For Jenkins-based pipelines that supplement your Bitbucket Pipelines setup, our Codacy Jenkins guide covers that integration in detail.

Alternative: CodeAnt AI for Bitbucket teams

If you are evaluating code quality tools and want to compare Codacy against alternatives that also support Bitbucket, CodeAnt AI is worth a close look. Priced at $24-40 per user per month, CodeAnt AI offers AI-powered code review that goes beyond pattern matching to catch logic errors, design issues, and context-dependent problems that rule-based static analysis tools often miss.

CodeAnt AI supports Bitbucket Cloud and integrates with Bitbucket Pipelines for CI/CD-based analysis. The setup process is similar to Codacy – install the Bitbucket app, store an API token as a repository variable, and add a pipeline step to trigger analysis. Where CodeAnt AI differs is in the analysis engine itself: it uses large language models to review code in context, making its feedback more similar to what you would get from a senior developer code review than from a linter.

For teams that find Codacy’s rule-based approach produces too many false positives or misses the kinds of issues that come up in human code reviews, CodeAnt AI’s AI-first model is a compelling alternative. For a full comparison across tools in this space, our Codacy alternatives guide covers CodeAnt AI, SonarQube, DeepSource, Semgrep, and others.

Troubleshooting

Coverage not showing on Bitbucket PR

If coverage data is uploaded but not appearing on the Bitbucket PR:

  • Check the pipeline logs – Look for errors in the coverage reporter output. Common errors include “project token not found” (wrong token), “commit not found” (SHA mismatch), and “report file not found” (wrong path).
  • Verify the coverage file exists – Add ls -la target/site/jacoco/ before the reporter step to confirm the file was generated.
  • Check the commit SHA – Ensure $BITBUCKET_COMMIT is the correct SHA. For PRs from forks, this may need adjustment.
  • Wait longer – Codacy processes coverage data asynchronously. Coverage typically appears within 2-3 minutes of upload, but can take longer for large repositories.

Codacy status check not appearing on PR

If the Codacy status check is absent from your Bitbucket PR entirely:

  • Verify the webhook – Check that the Codacy webhook exists in your repository settings and that recent deliveries succeeded (HTTP 200 response).
  • Check the app permissions – Ensure the Codacy app still has access to your workspace. Token revocation or workspace permission changes can break this.
  • Re-add the repository in Codacy – Sometimes removing and re-adding the repository in Codacy reinstalls the webhook and fixes the connection.

Quality gate API returning 404

A 404 from the Codacy API quality gate endpoint usually means:

  • The organization slug or repository slug in your API URL does not match exactly what Codacy expects. Use your Bitbucket workspace slug (the URL identifier, not the display name) and the exact repository slug from your Bitbucket URL.
  • The commit has not been analyzed yet. Increase the polling wait time.
  • The account API token does not have access to the repository. Verify the token is from an account that has access to the repository in Codacy.

Pipeline running on PRs from forks

Bitbucket Pipelines has specific behavior for PRs from forked repositories regarding repository variables. By default, repository variables are not available to fork pipelines for security reasons. If your contributors submit PRs from forks, the $CODACY_PROJECT_TOKEN variable will be unavailable in those pipelines.

To address this, you can either allow secured variables to be used in fork pipelines (check your Bitbucket pipeline settings) or accept that fork PRs will not have coverage uploaded – Codacy will still perform static analysis triggered by the webhook.

Summary

Integrating Codacy with Bitbucket is a two-part process: installing the Codacy Bitbucket app for automatic webhook-driven static analysis on PRs, and configuring bitbucket-pipelines.yml for coverage uploading and optional quality gate enforcement.

The app installation handles the analysis side automatically – once connected, every PR in your repository gets static analysis, SAST scanning, and inline code comments from Codacy without any pipeline configuration. The pipeline configuration layer adds coverage tracking and the ability to fail builds based on quality thresholds, rounding out the integration into a comprehensive automated quality system.

For Bitbucket Data Center users, Codacy Self-Hosted is the path forward – the same capabilities in an on-premise deployment that keeps your code within your own infrastructure.

If Codacy does not fit your needs after the evaluation, CodeAnt AI ($24-40/user/month) offers a strong AI-powered alternative with Bitbucket support, and our Codacy alternatives guide covers the full landscape of options. For a deeper look at Codacy itself, visit our Codacy tool page and our Codacy review for an honest assessment of its strengths and limitations.

Further Reading

Frequently Asked Questions

How do I connect Codacy to Bitbucket?

Install the Codacy app from the Bitbucket Marketplace under Settings then Find new apps, then authorize Codacy to access your workspace. Once authorized, go to app.codacy.com, sign in with your Bitbucket account, and add the repositories you want to analyze. Codacy will start analyzing pull requests automatically via webhooks that the app installs for you.

Does Codacy work with Bitbucket Pipelines?

Yes. Codacy integrates with Bitbucket Pipelines for coverage uploading and quality gate enforcement. You add pipeline steps to your bitbucket-pipelines.yml that download the Codacy Coverage Reporter, run it after your tests, and optionally query the Codacy API to check whether the commit passes your configured quality gate thresholds.

Does Codacy support Bitbucket Server (Data Center)?

Codacy supports Bitbucket Data Center (formerly Bitbucket Server) through Codacy Self-Hosted, the on-premise version of the platform. The cloud version at app.codacy.com supports Bitbucket Cloud only. Self-Hosted requires a dedicated deployment on your own infrastructure and is available on Enterprise plans.

How do I upload code coverage from Bitbucket Pipelines to Codacy?

Add a pipeline step that downloads the Codacy Coverage Reporter shell script with curl, then runs it pointing at your coverage report file and authenticated with your CODACY_PROJECT_TOKEN repository variable. Supported formats include LCOV, Cobertura XML, JaCoCo XML, Clover, and OpenCover. Store the project token as a repository variable in Bitbucket so it is not hardcoded in your YAML.

How do I set up Codacy quality gates with Bitbucket Pipelines?

Configure quality gate thresholds in the Codacy dashboard under Quality settings for your repository. In your bitbucket-pipelines.yml, after uploading coverage, query the Codacy API with your account token to check whether the commit’s isUpToStandards field is true or false. If it is false, exit the pipeline step with a non-zero code to fail the build. Use a polling loop to wait for Codacy to finish analysis before checking the gate.

What is the Codacy project API token and where do I find it?

The Codacy project API token authenticates the Coverage Reporter when uploading coverage data for a specific repository. Find it by going to your repository in Codacy, clicking Settings, then selecting the Coverage or Integrations section. Copy the project token and store it as a Bitbucket repository variable named CODACY_PROJECT_TOKEN so your pipeline can access it securely.

How do I get Codacy to comment on Bitbucket pull requests?

Codacy comments on Bitbucket pull requests automatically once you install the Codacy Bitbucket app and connect your repositories. No additional pipeline configuration is needed for PR comments – they are driven by Codacy’s webhook integration, which triggers static analysis whenever a PR is opened or updated. You can configure whether Codacy posts inline comments, a summary comment, or both in your Codacy repository settings.

How do I set up Codacy webhooks for Bitbucket?

Webhooks are configured automatically when you install the Codacy Bitbucket app. If you need to set up webhooks manually – for example in a self-hosted scenario – go to your Bitbucket repository settings, select Webhooks under Workflow, and add a webhook pointing to the Codacy webhook URL with push and pull request events enabled. The Codacy webhook URL is available in your repository’s Codacy settings under Integrations.

Why is Codacy not showing coverage on my Bitbucket PR?

The most common causes are a missing or incorrect CODACY_PROJECT_TOKEN, a coverage report path that does not match the actual generated file, or a commit SHA mismatch between what Pipelines reports and what Codacy expects. Also check that you are running the coverage upload step after the test step and that the test step actually generated a coverage file. Add an ls step in your pipeline to verify the file exists before running the reporter.

Can I use Codacy with Bitbucket branch restrictions?

Yes. You can require the Codacy status check to pass before merging a pull request by enabling branch restrictions in Bitbucket. Go to your repository settings, select Branch restrictions under Workflow, add a restriction for your target branch (e.g., main), and set the merge checks to require the Codacy status to be successful. This prevents any PR from being merged unless Codacy analysis passes.

Is there a Codacy alternative that supports Bitbucket?

CodeAnt AI ($24-40 per user per month) is a strong alternative that supports Bitbucket and provides AI-powered code review. SonarQube Cloud also integrates with Bitbucket Pipelines. DeepSource and Semgrep are additional options worth evaluating if you need different capabilities such as deep security scanning or a more flexible rule engine.

How long does Codacy analysis take on a Bitbucket PR?

Codacy typically completes analysis within 1 to 5 minutes for most repositories. Very large codebases with many files or slow analysis tools can take up to 15 minutes. The analysis runs in Codacy’s cloud infrastructure triggered by the Bitbucket webhook, so it does not affect your Bitbucket Pipelines build time directly. Coverage upload time from your pipeline is typically under 30 seconds.

Originally published at aicodereview.cc

Multi-Tenant SaaS Architecture: What Nobody Tells You Before You Build

Multi-tenancy is one of those decisions that looks simple on a whiteboard and complicated in production. Choosing the wrong isolation model at the start — or not consciously choosing at all — creates a class of problems that are genuinely hard to undo later. Here’s what you should know before you write the first migration.

The Three Isolation Models (and the Trade-offs Nobody Leads With)

Every multi-tenant SaaS sits somewhere on a spectrum between full isolation and full shared infrastructure. There are three canonical patterns:

1. Separate databases per tenant

Each tenant gets their own database instance. Full data isolation, no risk of cross-tenant data leakage, clean tenant offboarding, and trivial per-tenant backup and restore.

The trade-offs: provisioning time increases, connection pool management gets complicated fast, schema migrations need to run across N databases, and cost scales linearly with tenant count. This model makes sense when you have strong compliance requirements, enterprise clients who mandate data isolation, or when per-tenant customisation of the data model is a real product requirement.

2. Separate schemas, shared database

One database server, but each tenant gets their own schema. This is Postgres-native and is a reasonable middle ground: you get logical separation without the operational overhead of separate database instances.

The catch: connection pooling tools like PgBouncer work at the connection level, not the schema level. You’ll need to set the search_path correctly per request, and some ORMs handle this gracefully and some don’t. Schema migrations still need to be coordinated across all tenants.

3. Shared schema, shared database (row-level tenancy)

Every table has a tenant_id column. All tenants share the same tables. This is the cheapest to operate, the simplest to migrate, and the easiest to onboard new tenants into.

The risk is the one that bites teams most: if you forget to filter by tenant_id anywhere in your application, a tenant can see another tenant’s data. This is not a theoretical risk — it has caused real security incidents. The mitigation is row-level security (RLS) at the database layer.

Row-Level Security: Use It, Don’t Trust the Application Layer Alone

If you go with the shared schema approach, Postgres RLS is not optional — it’s the last line of defence.

-- Enable RLS on the projects table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- Create a policy that restricts reads to the current tenant
CREATE POLICY tenant_isolation ON projects
  USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

Then in your application, set the tenant context at the start of each request:

SET app.current_tenant_id = '{{tenant_uuid}}';

This means that even if your application code has a bug and omits the tenant_id WHERE clause, the database enforces the boundary. Defence in depth.

The cost: current_setting() calls add marginal overhead per query. In practice this is negligible for most workloads, but benchmark it if you’re operating at very high query rates.

The Tenant Resolution Problem

How does your application know which tenant a request belongs to? There are three common approaches, each with real implications for your routing and infrastructure:

Subdomain-based: acme.yourapp.com → resolve acme to a tenant.
Clean for end users. Requires wildcard TLS, and your CDN/load balancer needs to handle arbitrary subdomains. Custom domains (where a tenant uses app.acmecorp.com) add another layer of complexity: you need to map arbitrary domains to tenant IDs at the edge.

Path-based: yourapp.com/t/acme/dashboard
Simpler infrastructure. Less ergonomic for users. Works everywhere including native apps. Good for internal tooling or B2B products where users don’t care about the URL.

Token-based: tenant encoded in the JWT or session token.
Common for API-first products. Tenant context is explicit in every request. No DNS dependency. Slightly more complex auth middleware.

Most teams mix approaches: subdomain for the main app, token-based for the API.

Schema Migrations at Scale

This is the operational burden that takes teams by surprise. In a single-tenant app, a migration runs once. In a multi-tenant app with separate databases or schemas, it runs N times.

For shared-schema tenancy, this is not a problem — migrations run once. For schema-per-tenant or database-per-tenant, you need a migration runner that can:

  1. Track migration state per tenant
  2. Run migrations in parallel (with configurable concurrency)
  3. Handle failures gracefully — partial rollouts should not leave some tenants on schema version 7 and others on version 8 invisibly

A pattern that works well: maintain a tenant_migrations table in your management database that records the current migration version for each tenant. Your deployment pipeline queries this table, identifies tenants not at the current version, and runs migrations in batches.

Tenant Onboarding: Provisioning New Tenants Efficiently

In a shared-schema model, onboarding a new tenant is simply inserting a row into a tenants table and using the generated ID as the tenant_id in all subsequent writes. It’s fast, it’s atomic, and it doesn’t require any infrastructure changes.

In a schema-per-tenant model, onboarding requires creating a new schema and running the baseline migrations against it. This adds latency to the signup flow — typically seconds for small schemas, potentially minutes for large ones. The standard approach is to provision the tenant schema asynchronously and show a “getting your workspace ready” state to the user while it completes.

In a database-per-tenant model, provisioning is the most expensive: create a new database instance, configure access, run migrations, and update the routing table. This is typically a background job that takes minutes, and the user experience must be designed to accommodate it.

Plan your onboarding UX around your provisioning model. Don’t discover the mismatch after you’ve shipped.

Don’t Confuse Multi-Tenancy with Multi-Instance

One thing worth saying explicitly: multi-tenancy is a data architecture and application design concern, not an infrastructure concern. You can run a multi-tenant application on a single server or across hundreds. You can run a single-tenant application in Kubernetes with 50 replicas.

The isolation model lives in your data layer and your application logic. Infrastructure decisions about scaling, availability, and deployment are separate — important, but separate.

For a deeper look at the specific patterns and when to apply each, the Actinode guide on multi-tenant SaaS architecture covers the full decision matrix including compliance considerations and per-pattern migration strategies.

Summary

Model Isolation Cost Migration complexity Best for
Separate databases Full High High Enterprise, regulated industries
Separate schemas Logical Medium Medium Mid-market SaaS
Shared schema + RLS Row-level Low Low High-volume B2B, most startups

The choice you make here will be with you for years. Make it deliberately.

A Note on Testing Your Isolation Model

Whatever model you choose, write explicit tests that verify your tenant isolation holds. Not just unit tests for the query logic — integration tests that simulate cross-tenant access attempts and verify they fail at the database layer.

A test suite that catches isolation failures looks like this:

  1. Create two test tenants with separate datasets
  2. Authenticate as tenant A
  3. Attempt to read tenant B’s records via your application’s own API routes
  4. Assert the response contains zero tenant B records

This test should run in your CI pipeline on every merge. Tenant isolation failures are the kind of bug that makes the news, and the cost of catching them in CI is trivially low compared to the cost of discovering them in production.

The same principle applies to your RLS policies: write a test that connects to Postgres with the RLS policy active, sets app.current_tenant_id to tenant A’s ID, and attempts to SELECT tenant B’s rows directly. If RLS is working correctly, the query returns zero rows. If it returns any rows, your policy has a gap.

Isolation is a correctness property, not just a design preference. Test it like one.

I built a flashcard app after burning out on Anki — here’s what I learned

I used Anki for a long time.

It made sense back then. You had a word list,
you needed to memorize it, you drilled it
until it stuck. Simple. Effective enough.

But it’s 2026. And I think we need to have
an honest conversation about why Anki is
starting to feel like a fax machine in a
world of instant messaging.

The uncomfortable truth about flashcards

I was memorizing words. Not learning them.

There’s a difference — and it took me embarrassingly long to see it.

When you drill “ephemeral → short-lived” a hundred times, your brain stores a weak, isolated link. It knows the definition. It does not know the word.

But when you read “the beauty of cherry blossoms is ephemeral — they last only one week” — something different happens. Your brain attaches the word to a scene, an emotion, a moment. That’s the kind of memory that survives a real conversation.

This is called contextual encoding. It’s not a new idea — memory researchers have known this for decades. And yet almost every flashcard app ignores it completely.

I got frustrated enough to build
MindDory — an app
that uses AI to wrap your target vocabulary
in short contextual stories instead of
isolated cards.

So I built MindDory

The idea was simple: what if instead of isolated cards, an AI generated short stories around exactly the words you need to learn?

You read the story. The words appear in context. Your brain does what it’s actually designed to do.

I also added memory cues — smart keyword associations for the words that just refuse to stick — and spaced repetition to handle the review timing automatically.

Honestly? It felt like cheating compared to my old Anki routine.

Three things building this taught me

The hardest part had nothing to do with code.

I spent weeks just trying to understand why people fail at language learning. Reading research papers at midnight. Talking to strangers on Reddit about their Anki burnout. Watching where the drop-off happens.

The answer was always the same: isolation. Words learned without context don’t transfer to real life. Full stop.

My first version was too clever.

I stuffed it with features I thought were cool. Users opened the app, got confused, and left. So I stripped everything back to the core loop: encounter a word → see it in a story → review it later. That’s it. Everything else is noise.

Distribution is genuinely harder than building.

I’m a builder. I like building. But getting MindDory in front of people who actually need it? That’s a completely different skill set — and honestly the thing I’m still figuring out.

If you’ve shipped something and cracked distribution, I’d love to know how. Drop it in the comments. I mean it.

Where things stand

MindDory is live on the App Store. Free to try. English, French, Korean, Chinese and more.

If you’ve ever felt like Anki was grinding you down without actually making you fluent — that’s exactly who I built this for.

And if you have thoughts on contextual learning, memory science, or just want to tell me I’m wrong about something — please do. That’s the kind of conversation I’m here for.

If you’re curious what contextual learning
looks like in practice, I built MindDory
around this idea — minddory.com

5 MVP Development Mistakes That Kill Startups Before Launch

Most startups don’t fail after launch — they fail because of decisions made before a single line of production code was written. After seeing the same patterns repeat across dozens of early-stage builds, I want to document five of the most common MVP development mistakes and what they actually cost you.

Mistake 1: Treating the MVP as a Smaller Version of the Full Product

This is the most expensive mindset error you can make.

An MVP is not a product with features removed. It is a hypothesis with a delivery mechanism. The question is not “what’s the minimum we can ship?” — it is “what is the fastest way to learn whether this problem is real and whether our solution addresses it?”

When teams approach an MVP as a stripped-down full product, they still make the same architectural decisions, the same data model choices, and the same tech stack trade-offs they’d make for a mature system. The result: a system that takes 6 months to build and 3 months to change after you learn you got the core assumption wrong.

The fix: Start with user stories, not feature lists. Every piece of the MVP should be traceable to a specific assumption you need to validate. If you can’t name the assumption a feature is testing, cut the feature.

Mistake 2: Optimising for Scale Before You Have Users

Premature scalability is the startup equivalent of building a 10-lane highway before anyone has applied for a driving licence.

Multi-region deployments, event-driven microservices, database sharding strategies — these decisions have real costs: complexity, slower development, harder debugging, and more expensive infrastructure. They make sense when you have the load to justify them. In an MVP, they are often just risk without corresponding reward.

The default starting point for most web applications — a single server, a relational database, a simple monolith — will comfortably handle the traffic of the vast majority of early-stage products. The systems that fail at early-stage aren’t failing because the architecture wasn’t distributed enough; they’re failing because the product wasn’t validated enough.

Scale for the users you have plus a reasonable buffer. Save the architecture work for when you actually have the problem.

Mistake 3: Skipping Auth and Permissions “Until Later”

Authentication and authorisation are never “later” problems. They’re day-one problems, and retrofitting them into an existing system is disproportionately expensive.

The reason this mistake happens is understandable. Auth feels like plumbing. You want to build features. And in the very earliest prototype — a Figma walkthrough, a Typeform, a manual demo — you don’t need auth at all. But once you have real users interacting with a real system, the risk of skipping this is not theoretical. You’re one shared session cookie away from a user seeing another user’s data.

Modern auth libraries (Clerk, Auth0, NextAuth, Supabase Auth) have dramatically reduced the cost of getting this right from the start. There is no good reason to skip it.

Mistake 4: Building Without a Feedback Loop Mechanism

An MVP without feedback infrastructure is just software. The whole point is to learn, and learning requires a structured way to capture what users do and what they say.

This doesn’t mean building a full analytics platform. It means, at minimum:

  • Event tracking on the actions that matter (sign up, core feature use, upgrade, churn trigger)
  • A way for users to tell you something is broken or confusing (even a Typeform link)
  • Some form of session context so you can replay what happened before a support request

Many early teams ship without any of this, then try to diagnose growth problems by asking users in one-on-one calls. One-on-one calls are valuable, but they don’t scale and they’re subject to recall bias. Instrument your product from the start.

Mistake 5: Changing the Scope Mid-Build Without Updating the Timeline

Scope creep is normal. The decision to add a feature mid-sprint is sometimes the right one — you learn something that changes priorities. The problem is doing it without honest reckoning on what it costs.

Every new feature in an MVP build is not additive. It delays launch, adds surface area for bugs, and often introduces coupling that makes future changes harder. When scope changes happen without timeline adjustment, the team compensates by cutting quality: less testing, rushed implementation, deferred refactoring.

The discipline is simple but hard: when you add scope, remove an equivalent amount of scope or explicitly extend the timeline. Make the trade visible. Don’t absorb it invisibly into “we’ll just work harder.”

What an MVP Actually Needs to Succeed

Beyond avoiding these five mistakes, there are a few things that actively drive MVP success that don’t get talked about enough.

A clear definition of “validated.” Before building, write down the specific signal that would tell you the hypothesis is true. Not “people seem interested” — a concrete metric: 30 users completed the core flow, 10 paid, 5 renewed. Without a pre-defined threshold, confirmation bias fills the vacuum and teams convince themselves the signal is there when it isn’t.

Fast feedback cycles, not perfect execution. The goal of an MVP sprint is to reach a learning checkpoint as fast as possible. A good MVP process prioritises shippability and measurability over technical polish. You’re building the minimum surface area needed to generate a signal — then iterating based on what you learn, not on what you assumed.

Documented assumptions before you start. Write down what you believe to be true before you build. Not just the product assumptions — the market assumptions, the pricing assumptions, the onboarding assumptions. When you get data back, you’ll know what changed and what held. Teams that skip this step have no reliable way to learn from their own MVP results.

If you’re currently planning an MVP build, the roadmap matters as much as the stack. The Actinode guide on building from zero to your first 1,000 users walks through the phased approach in detail — what to build in each stage and what to deliberately leave out.

The common thread in all five mistakes above is the same: treating MVP development as a compression of product development, rather than a distinct discipline with its own goals and constraints. The two are not the same, and conflating them is expensive.

One More Thing: Define “Done” Before You Start

A final practical note that doesn’t fit neatly into any of the five mistakes but underpins all of them: define what “done” means for your MVP before you build it.

Not done as in “features shipped” — done as in “we’ve answered the question we set out to answer.” Write down the specific signal that would tell you your core hypothesis is true. A number of users, a conversion rate, a retention metric, a willingness-to-pay threshold. If you can’t write it down before you build, you won’t be able to read it accurately after you ship.

This definition protects you from two failure modes. The first is the MVP that ships but never generates a decision — you collect some signal, it’s ambiguous, and you keep iterating without ever committing to a direction. The second is the MVP that gets declared a success based on vanity metrics — pageviews, signups, demo requests — that feel like progress but don’t validate whether the core assumption holds.

Every good MVP has a clear question it’s trying to answer. That question should be written down before the first commit is made.

I Tried the Notion MCP Challenge — Can I Control My AI Agent?” ⭐

This is a submission for the Notion MCP Challenge

What I Built

I built a Governed MCP-Based AI Agent System where real-world actions are executed through tools — but always under strict policy control.

Instead of focusing only on what agents can do, this system enforces what they are allowed to do — and what must be blocked.

Core Idea

Use MCP as the capability layer and Actra as the governance layer:

  • MCP exposes real tools (Notion workspace actions)
  • The AI agent selects and invokes these tools
  • Actra evaluates every tool call before execution

This creates a system where:

Capability is separated from control.

How It Works (in practice)

In the demo:

  1. The agent connects to Notion via MCP
  2. It discovers available tools:
   notion-search
   notion-get-users
   notion-create-pages
  1. The agent attempts to execute actions

Step 1 — Uncontrolled Agent (Baseline)

No policy enforcement ❌
Agent executes tools freely
  • search works
  • user data can be accessed
  • write operations are possible

The agent has full power — with no guardrails.

Step 2 — Actra-Governed Agent

Actra is introduced as an in-process policy engine.
Every tool call is evaluated before execution.

What Gets Enforced

1. Input validation

Empty search →  Blocked
Rule: block_empty_search

2. Context-based control

safe_mode = true → Block writes
Rule: block_writes_in_safe_mode

The agent still knows about the tool — but cannot execute it.

What Makes This Different

Most AI systems:

  • rely on prompts or heuristics
  • enforce rules inconsistently
  • lack clear visibility into decisions

This system:

  • enforces policies deterministically at runtime
  • separates decision-making from control
  • provides explicit reasoning for every block

What This Enables

  • Safe AI agents for real-world workflows
  • Controlled access to sensitive operations
  • Clear auditability of decisions
  • Policy-driven execution instead of implicit behavior

Core Insight

MCP gives agents capability.
Actra decides whether that capability can be used.

This transforms AI agents from:

“systems that can act”

into:

systems that can act — safely, predictably, and under control.

What Makes This Different

Instead of blindly executing AI actions, every decision is evaluated against policies like:

  • ❌ Block sending sensitive data externally
  • ❌ Restrict unsafe API calls
  • ❌ Prevent unauthorized actions
  • ✅ Allow only whitelisted operations

This turns Notion + AI from a productivity tool into a safe execution environment for real-world workflows.

Video Demo

Show us the code

https://github.com/getactra/notion-mcp-governed-agent

Repo Structure

.
├── LICENSE
│   └── Project license

├── auth
│   ├── callback.ts
│   │   └── Handles OAuth redirect/callback after user authentication
│   ├── exchange.ts
│   │   └── Exchanges authorization code for access/refresh tokens
│   ├── metadata.ts
│   │   └── Fetches auth provider metadata (endpoints, configs)
│   ├── pkce.ts
│   │   └── Implements PKCE (Proof Key for Code Exchange) helpers
│   ├── register.ts
│   │   └── Actra MCP client registration with auth provider (client_id, etc.)
│   ├── state.ts
│   │   └── Save OAuth state
│   └── url.ts
│       └── Builds authorization URLs for login flow

├── mcp
│   └── client.ts
│       └── MCP (Model Context Protocol) client wrapper
│           handles communication with MCP server/service

├── package.json
│   └── Project dependencies, scripts, and metadata

├── test-step1.ts
│   └── Initial test/setup (MCP connection)

├── test-step2.ts
│   └── Next step test 

├── test-step3.ts
│   └── Intermediate flow test

├── test-step4.ts
│   └── Loads Notion MCP tools

├── test-step5-unsafe-agent.ts
│   └── Demonstrates an agent without safeguards
│       (To show risks accessing Notion without safeguards)

└── test-step6-actra-governed-agent.ts
    └── Agent with ACTRA governance layer
        (adds rules, constraints, or safety controls)```



### Example Policy



```yaml
version: 1

rules:
  # Block writes in safe mode
  - id: block_writes_in_safe_mode
    scope:
      action: notion-create-pages
    when:
      subject:
        domain: snapshot
        field: safe_mode
      operator: equals
      value:
        literal: true
    effect: block

  # Block empty search
  - id: block_empty_search
    scope:
      action: notion-search
    when:
      subject:
        domain: action
        field: query
      operator: equals
      value:
        literal: ""
    effect: block

How I Used Notion MCP

Notion MCP acts as the execution layer between an AI agent and real-world actions.

Instead of just reading data, the agent can:

  • discover available tools
  • execute operations (search, fetch, create, update)
  • interact with a live Notion workspace

Role of MCP in this system

In my setup, MCP is responsible for:

  • Tool discovery
  notion-search
  notion-get-users
  notion-create-pages
  • Tool execution
  client.callTool({ name, arguments })
  • Standardizing agent capabilities
    → every action becomes a structured tool call

What This Enables (and Why It’s Risky)

With MCP alone:

  • the agent can read workspace data
  • the agent can modify content
  • the agent can access users and metadata

In Step 5 (uncontrolled agent):

No policy enforcement ❌
Agent executes tools freely

This means:

The agent has full capability, but no control

Adding Actra (What Changes)

With Actra layered on top:

  • every tool call becomes a policy-evaluated action
  • execution is conditionally allowed or blocked
  • decisions are deterministic and explainable

In Step 6 (governed agent):

❌ Blocked by Actra
Rule: block_get_users

The agent still has capability — but no longer has unrestricted power

What This Unlocks

Without MCP:

  • Notion is just a UI or database

With MCP:

  • Notion becomes a programmable execution surface

With MCP + Actra:

  • It becomes a governed AI system
  • Actions are:

    • validated
    • controlled
    • auditable

Architecture

Notion (Workspace / Tools)
        ↓
     MCP Layer
   (Tool Discovery + Execution)
        ↓
     AI Agent
        ↓
   Actra Runtime
 (Policy Evaluation Engine)
        ↓
 Allowed / Blocked 
        ↓
   Tool Execution (or Denied)

Key Insight

MCP gives agents power.
Actra decides how that power is used.

Why This Matters

As AI agents become more powerful, governance becomes critical.

This project shows that:

  • you don’t need heavy infra
  • you don’t need external policy services

You can enforce deterministic, auditable control directly inside your application.

Future Work

  • Role-based policies (team / org level)
  • Policy simulation + testing UI inside Notion
  • Full MCP-native agent orchestration
  • Audit logs and explainability dashboards

Closing Thought

Everyone is building AI agents.

Very few are thinking about control, safety, and governance.

This project is a step toward making AI systems not just powerful — but trustworthy.

Developers Think AI Makes Them 24% Faster. It Actually Makes Them 19% Slower.

Developers using AI coding tools believe they’re 24% faster. Measured objectively, they’re actually 19% slower. That’s a 43-point perception gap, and it explains a lot about why AI tool adoption keeps rising while trust keeps falling.

The data comes from the METR study, which measured experienced open-source developers working on their own repositories with tasks they selected themselves. This wasn’t a lab experiment with toy problems. These were real developers doing real work, and the AI made them measurably less productive while making them feel more productive.

Here’s what’s actually happening, and why it matters for how you use these tools.

The perception gap is the finding

The headline “AI makes you slower” is catchy but misses the point. The real finding is that developers can’t tell whether AI is helping them or not.

The study found that developers consistently overestimated the time savings from AI assistance. They reported feeling faster, more confident, and more productive. The clock said otherwise. The gap came from time spent on activities that feel productive but aren’t: reviewing AI suggestions, debugging AI-generated code, re-prompting after bad outputs, and context-switching between their own thinking and the AI’s suggestions.

This matches what the Stack Overflow 2025 Developer Survey found at scale: trust in AI accuracy dropped to 29%, down from 40% the year before. 46% of developers actively distrust AI tool accuracy. Developers are using the tools more while trusting them less, which is a strange place to be.

Where AI actually helps (and where it doesn’t)

The METR study doesn’t say AI is useless. It says AI on familiar codebases with experienced developers doesn’t save time on average. That’s a specific finding about a specific context.

Other data tells a more nuanced story. The CodeRabbit analysis of 470 GitHub repositories found AI-authored code has 1.7x more issues than human-written code. But the distribution matters:

Task type AI advantage AI disadvantage
Boilerplate generation High (repetitive, low-context) Low
Bug fixing on unfamiliar code Medium (broad knowledge) Medium (misses local context)
Architecture decisions Low (needs full project context) High (hallucinates patterns)
Security-sensitive code Very low Very high (2.74x more XSS vulns)
Code explanation High (good at summarizing) Low
Refactoring existing code Low (METR finding) High (19% slower)

The pattern: AI excels at tasks where broad training data matters more than local project context. It struggles where deep understanding of a specific codebase is required. Refactoring your own code on a project you know well is the worst case for AI assistance because you already have the context the AI lacks.

Why experienced developers get less benefit

This is counterintuitive. You’d expect experienced developers to get more from AI tools because they can better evaluate and direct the output. Google Chrome engineering lead Addy Osmani made this argument and it’s partly right: experts do use AI more effectively than beginners.

But the METR study suggests a ceiling effect. When you already know a codebase well, the AI’s contribution is marginal. You can write the code yourself about as fast as you can prompt, review, then correct the AI’s attempt. The overhead of the human-AI collaboration loop (prompt, wait, read, evaluate, accept/reject, fix) eats the time savings from not typing the code yourself.

For beginners or developers working on unfamiliar codebases, the calculus is different. The AI brings knowledge the developer doesn’t have. But the GDC 2026 report found that among game developers who use AI, 81% use it for research and brainstorming, 47% for code assistance, and only 35% for prototyping. Most developers have already figured out that AI works better for exploration than execution.

What to actually do with this information

The METR study doesn’t mean you should stop using AI tools. It means you should be more intentional about when you use them.

Use AI for code you don’t know. Exploring a new library, understanding an unfamiliar codebase, generating boilerplate for a framework you’ve used twice. These are contexts where the AI’s broad training data genuinely helps.

Don’t use AI for code you know well. If you’ve written similar code 50 times, your fingers are faster than the prompt-review-fix loop. The METR study specifically found that experienced developers on familiar repositories were the ones who got slower.

Watch for the perception trap. If you feel like AI is making you faster, check the data. Track how long tasks actually take with and without AI. The 43-point perception gap means your intuition about AI productivity is probably wrong.

Treat AI output like a junior developer’s PR. Review it with the same scrutiny. The CodeRabbit data shows AI code has 1.75x more logic errors and 8x more excessive I/O operations. A quick skim is not sufficient review.

Use AI for understanding, not generation. “Explain this function” and “What does this error mean” are consistently the highest-value AI use cases across every study and survey. The generation use case gets all the marketing attention, but the comprehension use case delivers more actual value.

The productivity question is the wrong question

The deeper issue with the METR study is that “does AI make developers faster?” might be the wrong question. Speed is one metric. Code quality, security, maintainability, developer satisfaction are others.

The Cortex 2026 Engineering Benchmark found incidents per pull request increased 23.5% and change failure rates rose ~30% across engineering teams adopting AI tools. Developers are shipping more code, but the code is causing more problems.

If AI makes you feel 24% faster while actually making you 19% slower and producing 1.7x more bugs, the net effect on your project is significantly negative. The teams that figure this out and use AI selectively (for comprehension and exploration) rather than universally (for all code generation) will outperform those that don’t.

The tools will get better. The models will improve. But the perception gap will persist as long as developers rely on how AI-assisted coding feels rather than measuring what it produces.