The Power of Simple Tools: Why Sometimes All You Need is a Counter

As developers and tech enthusiasts, we often get caught up in the “feature race.” We build complex systems, integrate AI into everything, and obsess over syncing data across every possible cloud. But over time, I’ve realized that the tools people actually stick with are often the ones that do exactly one thing—and do it perfectly.

Think about the small, repetitive tasks that pop up throughout the day: counting inventory in a warehouse, tracking sets at the gym, or even just tallying attendance at a local meetup. These aren’t massive engineering problems, but they are frequent ones. When you’re in the middle of a task, the last thing you want is to wait for a bloated app to load, sit through a 5-second ad, or—worst of all—be forced to “Create an Account” just to start counting.

This is why lightweight, browser-based utilities still feel like magic in 2026. They are “zero-friction.” You open a tab, you do the work, and you close it. No strings attached.

Lately, I’ve been focusing on this philosophy of “minimalist utility.” I wanted to see how much I could strip away while still making a tool incredibly useful. The result is onlinecounter.org.

In building it, I focused on two specific pain points I noticed in other tools:

Mobile-First Interaction: Most web counters feel clunky on a phone. I wanted something that felt like a native app—responsive, snappy, and easy to tap with one hand while you’re on the move.

The “Copy” Workflow: Usually, after you finish counting, you need that number somewhere else—a spreadsheet, a text message, or a note. Instead of making users manually type it out, I added a “one-click copy” feature. It’s a small detail, but it saves a massive amount of hassle.

I also made a conscious choice to skip “cloud sync.” By keeping the data strictly in your local browser, the site stays lightning-fast and respects your privacy. No data leaves your device; it’s just you and your tally.

At the end of the day, not every problem needs a “platform.” Sometimes, the most effective tools are the ones that quietly do their job, stay out of your way, and let you get back to what really matters.

If you’re looking for a straightforward, ad-free way to keep track of… well, anything… feel free to give it a try:
https://onlinecounter.org/

Because sometimes, less really is more.

iPhone 20, dört kenardan kavisli tam ekran deneyimi sunabilir

iPhone Apple

iPhone AppleGeçtiğimiz günlerde bir Çinli kaynak, Apple’ın Face ID’ye güç veren donanımları ekranın altına almaktan uzak olduğunu iddia etmişti. Bu açıdan gerçek tam ekran deneyimi sunacak ilk iPhone’un yakında gelmeyeceği ve şirketin bunun yerine Dynamic Island’ı küçültmeye odaklandığı belirtilmişti. Kamera ve sensörleri ekran altına alma konusundaki geliştirme çalışmalarında sıkıntılar yaşadığı öne sürülen şirket, son bilgilere göre birçok kullanıcının hayalini 2027’nin sonunda tanıtılması beklenen iPhone 20 ile gerçeğe dönüştürebilir. Apple bu modelle birlikte dört kenardan kavisli yapıda tam ekran deneyimi (ekran altı selfie kamerası ve Face ID ile) sunabilir. Dört kenardan kavisli tasarımla neredeyse çerçevesiz bir cihaz elde edeceği öne sürülen Apple, 2026’nın eylül ayında karşımıza Dynamic Island’a sahip olacak iPhone 18 Pro, 18 Pro Max ve ilk katlanabilir iPhone modeliyle çıkacak. Çok merak edilen yatay formlu katlanabilir iPhone’da Face ID yerine yana entegre Touch ID parmak izi sensörünün yer alacağı aktarılıyor.

iPhone 20, dört kenardan kavisli tam ekran deneyimi sunabilir yazısı ilk olarak Teknoloji haberleri – LOG üzerinde yayınlandı.

The Lazy Developer’s Guide to Perfect Social Media Previews

You finally ship your project. You share the link on Twitter. And the preview looks like… nothing. A blank card. Or worse, some random text fragment from your footer.

I’ve been there. Multiple times. And it’s always the same problem: Open Graph tags.

What Are OG Tags?

Open Graph tags are meta tags in your HTML <head> that tell social platforms what to show when someone shares your link. Facebook invented the protocol, but Twitter, LinkedIn, Slack, Discord, and basically every platform uses them now.

Here are the important ones:

<head>
  <meta property="og:title" content="My Awesome Project" />
  <meta property="og:description" content="A short description of what this does" />
  <meta property="og:image" content="https://example.com/preview.png" />
  <meta property="og:url" content="https://example.com" />
  <meta property="og:type" content="website" />

  <!-- Twitter-specific (yes, they still need their own) -->
  <meta name="twitter:card" content="summary_large_image" />
  <meta name="twitter:title" content="My Awesome Project" />
  <meta name="twitter:description" content="A short description" />
  <meta name="twitter:image" content="https://example.com/preview.png" />
</head>

That’s it. Six to eight meta tags and your links look professional everywhere.

The 5 Mistakes Everyone Makes

1. Forgetting the Image

No og:image? Most platforms will either show nothing or try to grab a random image from your page. Sometimes it’s your logo. Sometimes it’s an icon. Sometimes it’s a tracking pixel. Fun.

2. Wrong Image Dimensions

The magic number is 1200×630 pixels. That’s the size that looks good everywhere. Go smaller and platforms will crop it weird or add gray bars. Go too big and some platforms reject it entirely.

3. Using a Relative Image URL

This won’t work:

<!-- BAD -->
<meta property="og:image" content="/images/preview.png" />

Social media crawlers don’t know your domain. Use the full URL:

<!-- GOOD -->
<meta property="og:image" content="https://yoursite.com/images/preview.png" />

4. Not Setting twitter:card

Without twitter:card, Twitter defaults to a tiny thumbnail. Add summary_large_image to get the full-width preview card. One line of HTML makes a massive difference.

5. Caching Issues

You fixed your tags but the preview still looks wrong? Social platforms cache aggressively. Twitter caches for about 7 days. Facebook has its own timeline.

You can force a refresh:

The Dynamic Image Problem

Static OG images work fine for your homepage. But what about blog posts? Product pages? User profiles? You can’t manually create an image for every page.

This is where OGPix comes in. It generates OG images dynamically through URL parameters.

Instead of creating images in Figma, you construct a URL:

<meta property="og:image"
  content="https://ogpix-pi.vercel.app/api/og?title=My+Blog+Post&theme=dark" />

Change the title parameter, get a different image. No design tools. No image hosting. No build step.

For a Next.js blog, it looks like this:

export async function generateMetadata({ params }) {
  const post = await getPost(params.slug);
  return {
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{
        url: `https://ogpix-pi.vercel.app/api/og?title=${encodeURIComponent(post.title)}`,
        width: 1200,
        height: 630,
      }],
    },
  };
}

Every blog post gets a unique, properly-sized preview image. Automatically.

Quick Checklist

Before you share any project link:

  • [ ] og:title is set and under 60 characters
  • [ ] og:description is set and under 155 characters
  • [ ] og:image uses an absolute URL and is 1200×630
  • [ ] twitter:card is set to summary_large_image
  • [ ] Test with Twitter Card Validator and Facebook Debugger
  • [ ] Preview looks good on mobile (text isn’t tiny)

Stop Shipping Blank Previews

Good OG tags take 5 minutes to add. Bad previews cost you clicks every single day. Your project deserves better than a blank card on Twitter.

Set up your meta tags. Use OGPix for dynamic images. Test before you share. That’s it.

It’s the laziest high-impact improvement you can make.

Evaluate LLM code generation with LLM-as-judge evaluators

Model Selector AI Config showing three variations: Sonnet, Opus, and Mistral with their corresponding model names.

Which AI model writes the best code for your codebase? Not “best” in general, but best for your security requirements, your API schemas, and your team’s blind spots.

This tutorial shows you how to score every code generation response against custom criteria you define. You’ll set up custom judges that check for the vulnerabilities you actually care about, validate against your real API conventions, and flag the scope creep patterns your team keeps running into. After a few weeks of data, you’ll have evidence to choose which model to use for which tasks.

What you will build

In this tutorial you build a proxy server that routes Claude Code requests through LaunchDarkly. You can forward requests to any model: Anthropic, OpenAI, Mistral, or local Ollama instances. Every response gets scored by custom judges you create.

You will build three judges:

  • Security: Checks for SQL injection, XSS, hardcoded secrets, and the specific vulnerabilities you care about
  • API contract: Validates code against your schema conventions
  • Minimal change: Flags scope creep and unnecessary modifications

After setup, you use Claude Code normally, and scores flow to the LaunchDarkly Monitoring dashboard automatically. Over time, you build a dataset grounded in your actual usage: maybe Sonnet scores consistently higher on security, but Opus handles API contract adherence better on complex endpoints. That’s the kind of answer a generic benchmark can’t give you.

To learn more, read Online evaluations or watch the Introducing Judges video tutorial.

Prerequisites

  • LaunchDarkly account with AI Configs enabled
  • Python 3.9+
  • LaunchDarkly Python AI SDK v0.14.0+ (launchdarkly-server-sdk-ai)
  • API keys for your model providers
  • Claude Code installed

How the proxy works

This proxy implements a minimal Anthropic Messages-style gateway for text-only code generation and automatic quality scoring.

When Claude Code sends a request to POST /v1/messages, the proxy:

  1. Extracts text-only prompts. It converts the Anthropic Messages body into LaunchDarkly LDMessages, keeping only text content. It ignores tool blocks, images, and other non-text content.

  2. Routes the request through LaunchDarkly AI Configs. The proxy creates a context with a selectedModel attribute. Your model-selector AI Config uses targeting rules on this attribute to pick the right model variation.

  3. Invokes the model and triggers judges. The proxy calls chat.invoke(). If the selected variation has judges attached, the SDK schedules judge evaluations automatically based on your sampling rate. Scores flow to LaunchDarkly Monitoring.

  4. Returns a standard Messages response. The proxy sends back the assistant response as a single text block, plus basic token usage if available.

Claude Code talks to a local /v1/messages endpoint. LaunchDarkly handles model selection and online evaluations behind the scenes.

Create the AI Config and judges

You can use the LaunchDarkly dashboard or Claude Code with agent skills. Agent skills are faster if you have them installed.1

Option A: Agent skills

Create the project:

/aiconfig-projects Create a project called "custom-evals-claude-code"

Create the model selector:

/aiconfig-create

Create a completion mode AI Config:
- Key: model-selector
- Name: Model Selector
- Project: custom-evals-claude-code

Three variations (empty messages, this is a router):
1. "sonnet" - Anthropic claude-sonnet-4-6
2. "opus" - Anthropic claude-opus-4-6
3. "mistral" - Mistral mistral-large@2407

Create the security judge:

/aiconfig-create

Create a judge AI Config with:
- Key: security-judge
- Name: Security Judge
- Project: custom-evals-claude-code
- Evaluation metric key: $ld:ai:judge:security

System prompt:
"You are a security auditor evaluating AI-generated code for vulnerabilities.

Analyze the assistant's response and score it from 0.0 to 1.0:

SCORING CRITERIA:
- 1.0: No security issues detected. Code follows security best practices.
- 0.7-0.9: Minor issues that pose low risk.
- 0.4-0.6: Moderate issues requiring attention.
- 0.1-0.3: Serious vulnerabilities present (SQL injection, XSS, command injection).
- 0.0: Critical vulnerabilities that could lead to immediate compromise.

CHECK FOR:
- Injection flaws (SQL, command, LDAP)
- Cross-site scripting (XSS)
- Hardcoded secrets or credentials
- Insecure file operations
- Missing input validation

If no code is present, return 1.0."

Use model gpt-5-mini with temperature 0.3.

Create the API contract judge:

/aiconfig-create

Create a judge AI Config with:
- Key: api-contract-judge
- Name: API Contract Adherence
- Project: custom-evals-claude-code
- Evaluation metric key: $ld:ai:judge:api-contract-adherence

System prompt:
"You are an API contract auditor. Evaluate whether AI-generated code adheres to the API schema.

SCORING CRITERIA:
- 1.0: Code fully complies with expected patterns.
- 0.5: Partial adherence with minor deviations.
- 0.0: Invalid format or significant violations.

If no API code is present, return 1.0."

Use model gpt-5-mini with temperature 0.3.

Create the minimal change judge:

/aiconfig-create

Create a judge AI Config with:
- Key: minimal-change-judge
- Name: Minimal Change Judge
- Project: custom-evals-claude-code
- Evaluation metric key: $ld:ai:judge:minimal-change

System prompt:
"You are a code review auditor focused on change scope. Evaluate whether the AI assistant made only necessary changes.

SCORING CRITERIA:
- 1.0: Changes are precisely scoped to the request. No unnecessary modifications.
- 0.5: Some unnecessary additions (reformatting unrelated code, extra comments).
- 0.0: Significant scope creep (rewriting large sections, architectural changes not requested).

FLAG THESE UNNECESSARY CHANGES:
- Reformatting code not part of the request
- Adding type annotations to unchanged functions
- Inserting unrequested comments or docstrings
- Renaming variables outside the scope of the fix

If no code changes present, return 1.0."

Use model gpt-5-mini with temperature 0.3.

Attach judges to the model selector:

/aiconfig-online-evals

Attach to all model-selector variations at 100% sampling:
- security-judge
- api-contract-judge
- minimal-change-judge

Set up targeting:

For each AI Config, go to the Targeting tab and edit the default rule to serve the variation you created. For the model selector, also add rules that match the selectedModel context attribute:

/aiconfig-targeting

For each judge (security-judge, api-contract-judge, minimal-change-judge):
- Set the default rule to serve the variation you created

For model-selector:
- Rule: if selectedModel contains "sonnet", serve Sonnet variation
- Rule: if selectedModel contains "mistral", serve Mistral variation
- Default rule: Opus variation

When the proxy sends selectedModel: "sonnet", LaunchDarkly returns the Sonnet variation. To learn more, read Target with AI Configs.

Option B: LaunchDarkly dashboard

Step 1: Create the model selector config

  1. Go to AI Configs and click Create AI Config.
  2. Set the mode to Completion, the key to model-selector, and name it “Model Selector”.
  3. Add three variations with empty messages (this config acts as a router):
    • Sonnet (key: sonnet) using claude-sonnet-4-6
    • Opus (key: opus) using claude-opus-4-6
    • Mistral (key: mistral) using mistral-large@2407

Model Selector AI Config showing three variations: Sonnet, Opus, and Mistral with their corresponding model names.

Step 2: Create the judge AI Configs

  1. Click Create AI Config and set the mode to Judge.
  2. Set the key (for example, security-judge) and name (for example, “Security Judge”).
  3. Set the Event key to the metric you want to track (for example, $ld:ai:judge:security).
  4. Add the system prompt with scoring criteria from the prompts in Option A.
  5. Set the model to gpt-5-mini with temperature 0.3.
  6. Repeat for each judge: security, API contract adherence, and minimal change.

Judge AI Config creation form showing mode set to Judge, event key field, system prompt with scoring criteria, and model configuration.

Step 3: Attach judges to the model selector

  1. Open the Model Selector AI Config and go to the Variations tab.
  2. Expand a variation (for example, Sonnet) and find the Judges section.
  3. Click Attach judges.

![Model Selector variation expanded showing the Judges section with an Attach judges button.]

  1. Select the judges you created and set the sampling percentage to 100%.
  2. Repeat for each variation.

Judge selection dropdown showing available judges with checkboxes, event keys, and sampling percentage fields.

Step 4: Configure targeting rules

  1. Go to the Targeting tab for the Model Selector.
  2. Add rules to route requests based on the selectedModel context attribute:
    • If selectedModel is mistral, serve the Mistral variation
    • If selectedModel is sonnet, serve the Sonnet variation
    • Default rule: serve Opus
  3. For each judge, set the default rule to serve the variation you created.

Targeting tab showing rules that route selectedModel values to the corresponding variations, with Opus as the default.

To learn more, read Custom judges.

Verify your setup

Before running the proxy, confirm in the dashboard:

  1. Model selector: Each variation shows three attached judges.
  2. Judges: Each judge prompt includes scoring criteria.
  3. Targeting: All AI Configs have targeting enabled with correct rules.

Set up the project

Create a directory and install dependencies:

mkdir custom-evals && cd custom-evals
python -m venv .venv && source .venv/bin/activate
pip install fastapi uvicorn launchdarkly-server-sdk launchdarkly-server-sdk-ai 
    launchdarkly-server-sdk-ai-langchain langchain-anthropic python-dotenv

Create .env:

LD_SDK_KEY=sdk-your-sdk-key-here
LD_AI_CONFIG_KEY=model-selector
MODEL_KEY=sonnet
ANTHROPIC_API_KEY=sk-ant-your-key-here
OPENAI_API_KEY=sk-your-key-here
PORT=9911

Build the proxy server

Create server.py with the following code.

Click to expand the complete proxy server code

"""
Proxy server for Claude Code with automatic quality scoring.

Routes requests through LaunchDarkly AI Configs and scores every response
with attached judges. Metrics flow to the LaunchDarkly Monitoring dashboard.
"""

import asyncio
import os
import logging
import uuid

import ldclient
from ldclient import Context
from ldai import AICompletionConfigDefault, LDAIClient, LDMessage
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import uvicorn

from dotenv import load_dotenv
load_dotenv()

LD_SDK_KEY = os.environ.get("LD_SDK_KEY")
LD_AI_CONFIG_KEY = os.environ.get("LD_AI_CONFIG_KEY", "model-selector")
PORT = int(os.environ.get("PORT", "9911"))

if not LD_SDK_KEY:
    raise ValueError("Missing LD_SDK_KEY environment variable")

LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
logging.basicConfig(level=getattr(logging, LOG_LEVEL, logging.INFO))

ld_config = ldclient.Config(LD_SDK_KEY)
ldclient.set_config(ld_config)
ld_client = ldclient.get()

if not ld_client.is_initialized():
    raise RuntimeError("LaunchDarkly client failed to initialize")

ai_client = LDAIClient(ld_client)
app = FastAPI()

# =============================================================================
# Message Conversion
# =============================================================================

def extract_text(content) -&gt; str:
    """Extract plain text from Anthropic-style content."""
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        texts = []
        for block in content:
            if isinstance(block, dict) and block.get("type") == "text":
                texts.append(block.get("text", ""))
        return "".join(texts)
    return str(content or "")


def convert_to_ld_messages(body: dict) -&gt; list[LDMessage]:
    """Convert Anthropic Messages API format to LDMessage format."""
    messages = []

    system = body.get("system")
    if system:
        system_text = extract_text(system) if isinstance(system, list) else system
        messages.append(LDMessage(role="system", content=system_text))

    for msg in body.get("messages", []):
        role_str = msg.get("role", "user")
        role = "assistant" if role_str == "assistant" else "user"
        messages.append(LDMessage(role=role, content=extract_text(msg.get("content", ""))))

    return messages

# =============================================================================
# Routes
# =============================================================================

@app.post("/v1/messages")
async def handle_messages(request: Request):
    """Main endpoint using chat.invoke() for automatic judge execution."""
    body = await request.json()
    user_key = request.headers.get("x-ld-user-key", "claude-code-local")

    # Build context with selectedModel for targeting
    model_key = os.environ.get("MODEL_KEY", "")
    context = (
        Context.builder(user_key)
        .set("selectedModel", model_key)
        .build()
    )

    fallback = AICompletionConfigDefault(enabled=False)
    chat = await ai_client.create_chat(LD_AI_CONFIG_KEY, context, fallback, {})

    if not chat:
        return JSONResponse(
            {"type": "error", "error": {"type": "unavailable", "message": "AI Config disabled"}},
            status_code=503
        )

    config = chat.get_config()
    model_name = config.model.name if config.model else "unknown"
    judge_count = len(config.judge_configuration.judges) if config.judge_configuration else 0

    print(f"[REQUEST] model={model_name}, judges={judge_count}")

    try:
        ld_messages = convert_to_ld_messages(body)

        if len(ld_messages) &gt; 1:
            chat.append_messages(ld_messages[:-1])

        last_message = ld_messages[-1] if ld_messages else LDMessage(role="user", content="")

        # invoke() executes judges automatically based on sampling rate
        response = await chat.invoke(last_message.content)

        # Await judge evaluations and log results
        if response.evaluations:
            print(f"[JUDGES] Awaiting {len(response.evaluations)} evaluations...")
            eval_results = await asyncio.gather(*response.evaluations, return_exceptions=True)
            for result in eval_results:
                if isinstance(result, Exception):
                    print(f"[JUDGE ERROR] {result}")
                elif result:
                    print(f"[JUDGE] {result.to_dict()}")

        # Flush events to LaunchDarkly
        ld_client.flush()
        await asyncio.sleep(0.1)

        response_text = response.message.content if response.message else ""

        # Get token metrics
        input_tokens = 0
        output_tokens = 0
        if response.metrics and response.metrics.usage:
            input_tokens = response.metrics.usage.input or 0
            output_tokens = response.metrics.usage.output or 0

        print(f"[METRICS] tokens={input_tokens}/{output_tokens}")

        return JSONResponse({
            "id": f"msg_{uuid.uuid4().hex[:24]}",
            "type": "message",
            "role": "assistant",
            "content": [{"type": "text", "text": response_text}],
            "model": model_name,
            "stop_reason": "end_turn",
            "usage": {
                "input_tokens": input_tokens,
                "output_tokens": output_tokens
            }
        })

    except Exception as e:
        ld_client.flush()
        logging.exception("Request failed")
        return JSONResponse(
            {"type": "error", "error": {"type": "internal_error", "message": str(e)}},
            status_code=500
        )


@app.get("/health")
async def health():
    return {"status": "ok", "launchdarkly": ld_client.is_initialized()}


@app.post("/v1/messages/count_tokens")
async def count_tokens(request: Request):
    return {"input_tokens": 0}

# =============================================================================
# Main
# =============================================================================

if __name__ == "__main__":
    print(f"Proxy running on port {PORT}")
    print(f"AI Config: {LD_AI_CONFIG_KEY}")
    print(f"Connect: ANTHROPIC_BASE_URL=http://localhost:{PORT} claude")
    uvicorn.run(app, host="127.0.0.1", port=PORT, log_level="info")

Connect Claude Code to your proxy

Start the proxy server:

python server.py

You should see output like:

Proxy running on port 9911
AI Config: model-selector
Connect: ANTHROPIC_BASE_URL=http://localhost:9911 claude

In a new terminal, launch Claude Code with the proxy URL and your chosen model:

MODEL_KEY=sonnet ANTHROPIC_BASE_URL=http://localhost:9911 claude

Every request now routes through your proxy. Watch the server logs to see judges executing:

[REQUEST] model=claude-sonnet-4-6, judges=3
[JUDGES] Awaiting 3 evaluations...
[JUDGE] {'evals': {'security': {'score': 1.0, 'reasoning': 'No vulnerabilities detected...'}}}
[JUDGE] {'evals': {'api-contract': {'score': 0.5, 'reasoning': 'Response uses correct endpoint...'}}}
[JUDGE] {'evals': {'minimal-change': {'score': 1.0, 'reasoning': 'Changes are focused...'}}}

The create_chat() and invoke() methods handle judge execution automatically:

chat = await ai_client.create_chat(config_key, context, fallback, {})
response = await chat.invoke(user_message)
# response.evaluations contains async judge tasks

Judge results are sent to LaunchDarkly automatically. You can optionally await response.evaluations to log results locally.

This proxy handles text-based conversations. Tool-based features like file editing and command execution won’t work through this proxy.

How model routing works

The MODEL_KEY environment variable controls which model handles requests. The proxy passes it as a selectedModel context attribute:

context = Context.builder(user_key).set("selectedModel", model_key).build()

Your targeting rules match this attribute and return the corresponding variation. Switch models by changing the environment variable:

MODEL_KEY=mistral ANTHROPIC_BASE_URL=http://localhost:9911 claude

Compare cloud and local models

To evaluate Ollama models against cloud providers:

  1. Add an “ollama” variation to your model-selector AI Config.
  2. Add a targeting rule for selectedModel equals “ollama”.
  3. Launch with MODEL_KEY=ollama.

Your custom judges score Claude Sonnet and Llama 3.2 with identical criteria. After enough requests, you can compare quality scores across providers.

Run experiments

After judges are producing scores, you can compare models statistically. Create two variations with different models, attach the same judges, and set up a percentage rollout to split traffic.

Your judge metrics appear as goals in LaunchDarkly Experimentation. After enough data, you can answer “Which model produces more secure code?” with confidence, not guesswork.

To learn more, read Experimentation with AI Configs.

Monitor quality over time

Judge scores appear on your AI Config’s Monitoring tab. To view evaluation metrics:

  1. Open your model-selector AI Config and go to the Monitoring tab.
  2. Select Evaluator metrics from the dropdown menu.

![Select Evaluator metrics from the dropdown]

  1. Each judge (security, API contract, minimal change) shows as a separate chart. Hover over a chart to see scores broken down by variation.

Security judge scores over time

API contract adherence scores

Minimal change judge scores

  1. To drill into a specific model’s evaluations, select the variation from the bottom menu.

Select a variation to see its evaluations

Watch for baseline patterns in the first week, then track regressions after model updates or prompt changes. Model providers ship updates without notice. A Claude update might improve reasoning but introduce patterns that fail your API contract checks. Set up alerts when scores drop below thresholds, and use guarded rollouts for automatic protection.

To learn more, read Monitor AI Configs.

Control costs with sampling

Each judge evaluation is an LLM call. Control costs by adjusting sampling rates:

  • Staging: 100% sampling to catch issues early
  • Production: 10-25% sampling for cost efficiency

You can also use cheaper models (GPT-4o mini) for staging and more capable models for production.

What you learned

The value is in the judges you create. The three in this tutorial cover security, API compliance, and scope discipline. Your team might care about different signals: documentation quality, test coverage, or adherence to internal coding standards.

Custom judges let you define quality for your codebase, apply the same evaluation criteria across models, and track trends over time. Once you create a judge, you can attach it to any AI Config in your project.

Ready to build custom judges for your codebase? Start your 14-day free trial and deploy your first evaluation today.

Next steps

  1. The /aiconfig-online-evals and /aiconfig-targeting skills are not yet available. Use the dashboard to complete those steps. ↩

What Happens to Your Calls After 5 PM? An Honest Cost Comparison

It’s 6:17 PM. A prospect who’s been thinking about your product all day finally has time to call. They’ve been on your website, read the comparison page, they’re ready to talk to someone. They dial your number and get: “Our office hours are Monday through Friday, 8 AM to 5 PM. Please leave a message and we’ll return your call on the next business day.”

That prospect hangs up. Sixty percent of after-hours callers don’t leave a message. Of those who do, 30-40% don’t answer when you call back the next day. By the time you reach them — maybe 48 hours later — they’ve talked to your competitor who answered the phone at 6:17 PM.

Measure the Problem Before Solving It

Before picking a solution, pull your inbound call data by hour. If you’re running VICIdial:

SELECT
    HOUR(call_date) AS call_hour,
    COUNT(*) AS total_calls,
    SUM(CASE WHEN status IN ('DROP','XDROP','AFTHRS','NANQUE')
        THEN 1 ELSE 0 END) AS missed,
    ROUND(SUM(CASE WHEN status IN ('DROP','XDROP','AFTHRS','NANQUE')
        THEN 1 ELSE 0 END) / COUNT(*) * 100, 1) AS miss_pct
FROM vicidial_closer_log
WHERE call_date >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY HOUR(call_date)
ORDER BY call_hour;

For a typical B2B operation running 8-5, you’ll find 300-400 missed calls per month outside business hours. Between 5 PM and 10 PM alone, a mid-size operation misses 200+ calls monthly. Add early morning and weekends, and the number grows.

Now multiply by your prospect percentage (about 60% of after-hours callers are genuine prospects — the rest are existing customers, vendors, spam), your conversion rate if they reached a human (8-12%), and your average deal value. For many operations, the after-hours revenue leak is $200K-$450K per year.

That’s the number that gets attention in a budget meeting.

Option 1: IVR Self-Service

Monthly cost: essentially $0 in VICIdial (built-in Call Menu system) plus ~$10-15 in inbound trunk minutes.

Set up an after-hours Call Menu with options: press 1 to leave a callback request, press 2 for business hours and directions, press 3 for emergency (routes to on-call cell phone), press 4 for voicemail. Configure time-based routing so calls automatically switch to the after-hours menu outside your defined Call Time hours.

Capture rate: 35-45% of callers. IVR works when callers need information (hours, order status, appointment confirmation) or when tasks are well-defined (payment processing). It fails when callers are prospects wanting a conversation before they buy, when your product requires explanation, or when callers are frustrated and need a human voice.

The big advantage: nearly free. The big problem: more than half your callers hang up without interacting.

Option 2: Voicemail With Callbacks

Monthly cost: about $830 in agent time (2 hours/day processing voicemails and making callbacks at $15/hr) for 350 after-hours calls.

The real funnel looks like this: 100 callers reach your voicemail. 40 leave a message (60% hang up). 28 answer your callback the next morning. 14 are still interested (the other 14 already found another solution or lost interest overnight). 2 convert. That’s a 2% capture rate from the original 100 callers.

If voicemail is your only option, optimize it: keep the greeting under 15 seconds (every extra second reduces the message-leaving rate by about 2%), call everyone back within 30 minutes of opening in the morning (speed-to-lead research shows contacting within 5 minutes yields 900% higher conversion than waiting an hour — overnight messages are already stale, don’t make them staler), and attempt 3 callbacks per lead before moving on.

Option 3: AI Voice Agents

Monthly cost: $400-1,400 for 350 calls (typically $0.10-0.25/minute plus a subscription fee).

Capture rate: 65-80% of callers. This is the number that matters — double the IVR rate and 6x the voicemail rate. Callers don’t hang up because they hear a voice, start talking, and the AI keeps the conversation going long enough to collect their information and qualify their intent.

In 2026, AI voice agents are good at answering FAQs, collecting caller information (name, phone, email, what they need), scheduling callbacks with calendar integration, providing order status from a database, and routing emergencies to on-call staff. They’re still not great at complex troubleshooting, emotional conversations (complaints, angry customers), negotiations, or calls in very noisy environments.

The technology has gotten remarkably good in the last 18 months. Latency has shrunk below 500ms for well-architected systems. Most callers can’t tell the difference for the first 30-60 seconds.

Integration with VICIdial: route after-hours calls via SIP transfer to the AI platform. When the AI finishes a call, it posts lead data back to VICIdial via the non-agent API:

curl "https://your-vicidial/vicidial/non_agent_api.php?
source=ai_afterhours&user=apiuser&pass=apipass123&
function=add_lead&phone_number=5551234567&
first_name=John&last_name=Smith&
comments=Called+6:45PM.+Wants+premium+plan.+Budget+approved.&
list_id=9999&status=AICLBK"

The lead shows up in the morning callback list with full context — what the caller wants, their budget, when they prefer a callback. Your agent calls John at 8:30 AM knowing exactly what he needs, instead of listening to voicemail number 7 of 14.

The Hybrid Approach (What Actually Works)

The right answer for most operations is a combination:

  • Simple inquiries (hours, directions, status checks): IVR self-service, cost ~$0
  • Prospect/sales calls: AI voice agent captures lead info and qualifies intent
  • Existing customer with an issue: AI attempts resolution, schedules priority callback if unresolved
  • Emergencies: direct route to on-call number via IVR option
  • Caller refuses AI/IVR: voicemail as last resort

The design decision that matters most: make the AI the default action on timeout. Most callers who don’t press buttons get connected to the AI assistant. Those who specifically want voicemail can press a button for it. This maximizes capture rate while giving callers control.

The Full Cost Comparison

For 350 after-hours calls per month, assuming 12% close rate and $1,500 average deal value:

Approach Monthly Cost Capture Rate Leads/Month Revenue Captured ROI
Voicemail only $830 11% 39 $7,500 8x
IVR only $20 40% 140 $25,500 1,275x
AI voice agent $800 75% 263 $48,000 59x
Hybrid $500 72% 252 $45,000 89x

Voicemail costs roughly the same as the hybrid approach but captures a fraction of the leads. IVR is cheapest in absolute dollars but only works for self-service tasks. The hybrid gives you nearly the capture rate of pure AI at about 60% of the cost.

Decision Framework

Under 10 calls per night: IVR with voicemail fallback. The volume doesn’t justify AI costs. Make the voicemail greeting short and call everyone back by 8:30 AM.

10-50 calls per night: Hybrid IVR + AI. The AI captures enough leads to pay for itself.

50+ calls per night: Seriously consider a night shift. A part-time agent (4 hours at $15/hr, $60/night, $1,800/month) may outperform AI for complex products at this volume.

Regulated industries (healthcare, legal, financial): Compliance review first. AI in regulated industries needs to handle consent, disclosure, and recording requirements correctly.

Measuring After-Hours Performance

Whatever approach you choose, measure these KPIs weekly:

After-hours capture rate: percentage of after-hours callers who leave info or engage with the AI/IVR. Target: 60%+. If you’re below 40%, the IVR flow is too complicated or the AI isn’t handling your common scenarios well.

Callback connection rate: percentage of callback requests that result in a live conversation the next day. Target: 70%+. If it’s below 50%, your callbacks are too slow — the leads have gone cold overnight.

After-hours conversion rate: percentage of after-hours leads that eventually convert, compared to your business-hours rate. If the gap is more than 50%, your callback process needs work. The leads aren’t worse — your handling of them is.

Speed to callback: average time between the after-hours call and the return callback. Under 30 minutes from open is the target. Every hour of delay after 8:30 AM reduces the probability of reaching the caller by about 12%.

IVR completion rate: for self-service tasks, what percentage of callers complete the task without hanging up? If it’s under 40%, the IVR menu has too many options or the prompts are too long.

Implementation Timeline

Week 1: audit your data and calculate the revenue impact with real numbers — don’t guess, pull the SQL and do the math. Week 2: deploy the after-hours IVR in VICIdial (takes a day for basic Call Menu setup). Weeks 3-4: test 2-3 AI voice agent platforms with your actual call scenarios — test latency, FAQ accuracy, lead capture completeness, and API integration. Don’t pick the best demo; pick the one that handles your edge cases. Week 5: deploy the hybrid, wire up the API integration, test end-to-end (call after hours, verify the AI captures info, verify the lead appears in VICIdial, verify agents see the context). Week 6+: run performance queries weekly, tune AI responses based on call recordings, adjust the IVR menu based on which options callers actually use.

The whole system can be live in 5 weeks if you don’t overthink it. The IVR layer takes a day. The AI integration takes a week if the platform has decent documentation. The rest is testing and tuning.

The after-hours call is the most neglected moment in the customer journey. It’s also one of the cheapest to fix. An IVR costs nothing. An AI voice agent costs less than a parking space. And the leads that call at 6 PM on a Wednesday are some of the warmest leads you’ll ever get — they picked up the phone and called you, voluntarily, after thinking about your product all day. Don’t waste them on a voicemail box that 60% of them won’t use.

For the complete implementation with VICIdial Call Menu configurations, time-based routing setup, AI platform integration code, and performance tracking SQL queries, see the full guide at ViciStack.

Lean Caching – A Custom NetworkBoundResource in Kotlin

Orchestrating data between a remote API and a local database is a foundational problem in mobile development. While the industry consensus rightly leans toward the Single Source of Truth pattern, the implementation often degrades into massive, fragile repository classes littered with boilerplate try-catch blocks and manual thread switching.

Solving this does not require a heavy, third-party state-management library. A clean, generic networkBoundResource built with Kotlin Flows and Coroutines is highly effective, provided you leverage Kotlin’s advanced language features correctly.

Let me explain by demonstrating this pattern in my RandomPokemon project. By focusing on reified generics, inline functions, and structured concurrency, I’ve created a highly fault-tolerant data layer that I drop into my projects to handle caching without the bloat.

State Representation: Rethinking ResultState

Orchestrating complex data flows requires a strict contract for state representation. A standard sealed class handles the basic loading, success, and error states, but the mechanism used to unwrap that state dictates the cleanliness of the presentation layer. The versatility of the ResultState lends itself well to be used in API call returns, I/O operations, or for use cases being observed by the ViewModel.

sealed class ResultState {
    data class Complete<out O>(val data: O?) : ResultState()
    data class Running(val message: String? = null) : ResultState()
    data class Error(val error: Throwable? = null) : ResultState()

    inline fun <reified O> parse(
        onRunning: (String?) -> Unit = {},
        onError: (Throwable?) -> Unit = {},
        onComplete: (O?) -> Unit = {},
    ) {
        when (this) {
            is Running -> onRunning(this.message)
            is Error -> onError(this.error)
            is Complete<*> -> this.data?.let {
                it.takeIf { it is O }?.let { result -> onComplete(result as O?) }
                    ?: run { onError(IllegalArgumentException("Expected output of type ${O::class.simpleName}; got ${data?.let { data::class.simpleName }}")) }
            } ?: run { onComplete(null) }
        }
    }
}

Standard state implementations often force the caller to write repetitive when statements and perform unsafe casts in the UI or ViewModel. I intentionally avoid this by relying on the inline fun parse function to safely handle type checking and casting at runtime. If the type does not match, it gracefully routes to the onError block, preventing an abrupt ClassCastException and keeping the app’s crash-free rate intact.

The Orchestrator: networkBoundResource

My core orchestrator is a single, highly optimized function. Relying heavily on inline and crossinline, it eliminates the runtime overhead associated with the higher-order functions passed into it.

inline fun <reified DomainType, reified DtoType> networkBoundResource(
    crossinline query: () -> Flow<ResultState>,
    crossinline fetch: suspend () -> ResultState,
    crossinline saveFetchResult: suspend (DtoType) -> Unit,
    crossinline shouldFetch: (DomainType?) -> Boolean = { it == null },
    crossinline mapDtoToDomain: (DtoType) -> DomainType,
    crossinline onCachePresent: () -> Unit = {}
) = flow {
    emit(ResultState.Running("Checking cache..."))
    val cachedDataResult = query().last()

    var cachedData: DomainType? = null
    cachedDataResult.parse<DomainType>(
        onError = { Timber.w(it, "Cache returned an error.") }
    ) { cachedData = it }

    if (shouldFetch(cachedData)) {
        emit(ResultState.Running("Cache miss. Fetching from remote..."))

        when (val remoteResult = fetch()) {
            is ResultState.Complete<*> -> {
                val dto = remoteResult.data as? DtoType

                if (dto != null) {
                    emit(ResultState.Complete(mapDtoToDomain(dto)))

                    supervisorScope {
                        launch {
                            try {
                                saveFetchResult(dto)
                            } catch (e: Exception) {
                                Timber.e(e, "Failed to save network resource to cache.")
                            }
                        }
                    }
                } else {
                    emit(ResultState.Error(IllegalStateException("Type mismatch: Remote fetch did not return the expected DTO.")))
                }
            }
            is ResultState.Error -> emit(remoteResult)
            is ResultState.Running -> emit(remoteResult)
        }
    } else {
        onCachePresent()
        emit(ResultState.Complete(cachedData!!))
    }
}

Two specific architectural decisions drive this implementation, focusing on performance and fault tolerance:

  1. Immediate UI Emissions: When the remote fetch succeeds, the code emits the Complete state before initiating the database save. Disk I/O is slow. Forcing the UI to wait for Room to finish inserting records is an unnecessary bottleneck. The user gets the mapped domain model instantly.

  2. Detached Caching: Launching saveFetchResult inside a supervisorScope acts as a defensive shield. If the database insertion fails due to a constraint violation or disk issue, the exception is caught and logged by Timber. The overarching coroutine running the Flow does not crash, and the UI retains the successfully fetched in-memory data.

Implementation: Concurrent Caching

An abstraction is only as effective as its call site. Integrating this into my PokemonRepoImpl demonstrates how I handle complex, relational data saving without blocking the thread.

class PokemonRepoImpl(
    private val remote: PokemonRepo.Remote,
    private val local: PokemonRepo.Local,
    private val dispatchers: DispatcherProvider
) : PokemonRepo {
    override fun getPokemonById(id: Long) = networkBoundResource<PokemonDetails, PokemonDto>(
        query = { local.getPokemonById(id) },
        fetch = { remote.getPokemonById(id).last() },
        saveFetchResult = { dto -> cacheRemotePokemon(dto.toDomainModel()) },
        mapDtoToDomain = { dto -> dto.toDomainModel() }
    )

    private suspend fun cacheRemotePokemon(details: PokemonDetails) {
        supervisorScope {
            val saveOperations: List<Pair<String, suspend () -> Unit>> = listOf(
                "pokemon" to { local.savePokemon(details.pokemon) },
                "cries" to { local.saveCries(details.cries) },
                "sprites" to { local.saveSprites(details.sprites) },
                "abilities" to { local.saveAbilities(details.abilities) }
            )

            saveOperations.forEach { (name, action) ->
                launch(dispatchers.io) {
                    try {
                        action()
                    } catch (e: Exception) {
                        Timber.e(e, "Error caching $name")
                    }
                }
            }
        }
    }
}

Mapping a large API payload to a relational database requires splitting the data into multiple tables. Executing these inserts sequentially scales poorly.

By wrapping the inserts in a supervisorScope and iterating through a list of saveOperations with launch(dispatchers.io), this repository performs concurrent database inserts. More importantly, the supervisorScope ensures fault isolation. If the API returns a malformed URL for a sprite that violates a database constraint, that specific coroutine fails and logs an error, but the parent scope remains active, allowing the pokemon, cries, and abilities to be cached successfully.

Final Thoughts

This implementation is my take on a pragmatic approach to Android architecture. Combining reified generics for type safety, inline functions for zero-overhead abstractions, and structured concurrency for fault-tolerant caching – yields a repository layer that is highly readable and extremely resilient. It solves a universal problem by leaning on the language, entirely avoiding the need for bloated external dependencies.

Stop Using the APIC GUI: Automate Cisco ACI with Terraform and Nexus-as-Code

If your data center team is still provisioning ACI tenants through point-and-click in 2026, the tooling isn’t the problem — the tooling has been mature for years. Terraform’s ACI provider shipped in 2019. Cisco’s Nexus-as-Code removed the HCL learning curve in 2022. Brownfield import means zero excuses for existing fabrics.

This guide walks through the full path: raw Terraform HCL → Nexus-as-Code YAML → CI/CD pipeline with peer-reviewed network changes. Whether you’re managing 5 tenants or 500, the workflow is the same.

Why Terraform for ACI?

Manual APIC GUI provisioning takes 15–30 minutes per tenant with VRF, bridge domain, and EPG creation. A terraform apply does the same in under 60 seconds. But speed is the least interesting benefit — the real value is drift detection, peer review, and rollback capability.

The critical distinction vs. a Python script: idempotency. A script that creates a tenant will fail or create a duplicate if you run it twice. Terraform checks current state first — if the tenant exists and matches your code, it does nothing. If someone manually changed the VRF through the GUI, terraform plan shows exactly what drifted.

Approach Re-run Behavior Drift Detection Rollback Best For
Python + APIC REST Fails or duplicates None Manual backup Quick prototyping
Ansible ACI Modules Idempotent per task Limited Re-run playbook Config pushes
Terraform ACI Provider Idempotent by design Built-in state comparison apply with previous code Full lifecycle mgmt
Nexus-as-Code (NAC) Idempotent + YAML Built-in + schema validation git revert + apply Enterprise scale

Raw Terraform: Your First ACI Tenant

The CiscoDevNet/aci provider (v2.x) supports 90+ resources. Every HCL resource maps 1:1 to an ACI managed object — aci_tenant → fvTenant, aci_vrf → fvCtx, aci_bridge_domain → fvBD.

# providers.tf
terraform {
  required_providers {
    aci = {
      source  = "CiscoDevNet/aci"
      version = "~> 2.15"
    }
  }
}

provider "aci" {
  username = var.apic_username
  password = var.apic_password
  url      = var.apic_url
  insecure = false
}

# main.tf
resource "aci_tenant" "prod" {
  name        = "PROD-Web"
  description = "Production web services tenant"
}

resource "aci_vrf" "prod_vrf" {
  tenant_dn              = aci_tenant.prod.id
  name                   = "PROD-VRF"
  ip_data_plane_learning = "enabled"
}

resource "aci_bridge_domain" "web_bd" {
  tenant_dn          = aci_tenant.prod.id
  name               = "WEB-BD"
  relation_fv_rs_ctx = aci_vrf.prod_vrf.id
  arp_flood          = "no"
  unicast_route      = "yes"
}

terraform init → plan → apply. Entire operation: under 10 seconds against a lab APIC.

Auth: Don’t Hardcode Credentials

export ACI_USERNAME=admin
export ACI_PASSWORD=$(vault kv get -field=password secret/apic)
export ACI_URL=https://apic1.lab.local

The provider reads ACI_USERNAME, ACI_PASSWORD, and ACI_URL automatically. For production, integrate with HashiCorp Vault or your org’s secrets manager.

Nexus-as-Code: YAML Instead of HCL

Nexus-as-Code (NAC) is a Cisco-maintained Terraform module with 150+ sub-modules that translates plain YAML into Terraform ACI resources. Instead of writing individual HCL blocks for every object, you define your fabric in YAML and NAC handles the translation.

The same tenant from above in NAC YAML:

# data/tenants.yaml
apic:
  tenants:
    - name: PROD-Web
      description: "Production web services tenant"
      vrfs:
        - name: PROD-VRF
          ip_data_plane_learning: enabled
      bridge_domains:
        - name: WEB-BD
          vrf: PROD-VRF
          arp_flood: false
          unicast_route: true
          subnets:
            - ip: 10.1.100.1/24
              public: true
              shared: false
      application_profiles:
        - name: Web-App
          endpoint_groups:
            - name: Web-EPG
              bridge_domain: WEB-BD
              physical_domains:
                - PHY-DOM

And the entire main.tf:

module "aci" {
  source  = "netascode/nac-aci/aci"
  version = "0.9.3"

  yaml_directories = ["data"]
}

That’s it. NAC parses the YAML, creates the resources, handles dependency ordering, and manages relationship bindings. For a network engineer who knows ACI but isn’t a developer, this is the difference between a 2-week HCL learning curve and a 2-hour one.

How NAC Works Under the Hood

  1. Your YAML files define desired ACI state in data/
  2. main.tf loads YAML via yaml_directories
  3. NAC root module parses and routes objects to sub-modules
  4. Sub-modules contain aci_rest_managed resources → APIC REST API calls
  5. Terraform state records what was deployed for drift detection

You never touch raw API calls — only YAML files.

Brownfield Import: The Step Everyone Skips

This is the single most critical step for existing ACI deployments — and the one most commonly skipped.

Without importing existing objects, terraform apply either creates duplicates (APIC rejects with 400) or creates conflicting configs. Both outcomes are bad.

Bulk Import with nac-import

# Clone and run
nac-import --url https://apic1.lab.local --username admin

# Review generated YAML
ls data/

# Verify — plan should show zero changes
terraform init && terraform plan

# Commit — your fabric is now under version control
git add . && git commit -m "Import existing ACI fabric"

Selective Import

If you only want specific tenants under Terraform control:

terraform import aci_tenant.prod uni/tn-PROD-Web
terraform import aci_vrf.prod_vrf uni/tn-PROD-Web/ctx-PROD-VRF
terraform plan  # Should show no changes for imported resources

Remote State: Don’t Keep It on Your Laptop

The state file is Terraform’s record of every managed object. Without it, Terraform can’t detect drift or plan changes. State locking prevents two engineers from running apply simultaneously.

Backend Locking Best For Cost
HCP Terraform Built-in Managed runs + approval UI Free tier
S3 + DynamoDB DynamoDB lock AWS environments ~$1/mo
Azure Blob Blob lease Azure environments ~$1/mo
GitLab Managed Built-in GitLab CI/CD teams Included
terraform {
  backend "s3" {
    bucket         = "aci-terraform-state"
    key            = "prod/aci/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

Separate state per environment and per fabric. A destroy on dev can’t touch production.

CI/CD Pipeline: Peer-Reviewed Network Changes

The ultimate win — treating network changes like software deployments:

# .github/workflows/aci-deploy.yml
name: ACI Terraform Pipeline
on:
  pull_request:
    paths: ['aci/**']
  push:
    branches: [main]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init -backend=false
      - run: terraform validate
      - run: terraform fmt -check -recursive

  plan:
    needs: validate
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init
      - run: terraform plan -out=tfplan -no-color

  apply:
    needs: plan
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production  # Manual approval gate
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init
      - run: terraform apply -auto-approve

The environment: production gate requires a designated approver to click “Approve” in GitHub before apply runs. Automated, auditable, enforced.

Common Pitfalls

Pitfall Consequence Fix
Skip brownfield import 400 errors or duplicate objects Always nac-import or terraform import first
Local state file Lost laptop = lost state Remote backend with locking on day one
Single state for all envs Dev mistake destroys prod Separate state per env/fabric
Hardcoded credentials Secrets in Git Environment variables + Vault
No plan review Unreviewed changes hit production CI/CD with mandatory approval gate
Monolithic main.tf 2000-line files nobody can review Split: tenants.tf, access.tf, fabric.tf

Pin Your Provider Version

required_providers {
  aci = {
    source  = "CiscoDevNet/aci"
    version = "~> 2.15"
  }
}

The ~> operator allows patch updates (2.15.x) but blocks minor bumps (2.16.0). Provider updates have historically changed attribute names — a floating version can break your plan unexpectedly.

FAQ

Do I need programming experience?
No. HCL is declarative, not a programming language. With NAC, you only write YAML. Git fundamentals matter more than coding.

Terraform vs. Ansible for ACI?
Terraform manages full lifecycle declaratively (create, update, destroy with state tracking). Ansible is imperative and task-oriented. Common pattern: Terraform for day-0/day-1 provisioning, Ansible for day-2 operations.

How do I handle existing ACI configs?
Use nac-import for bulk import or terraform import for selective management. Never write HCL for objects that already exist without importing them first.

Originally published at FirstPassLab. For more deep-dive networking guides, check out firstpasslab.com.

AI Disclosure: This article was adapted from human-researched content with AI assistance for formatting and Dev.to optimization.

YÖK Başkanı Özvar: Tarım ve hayvancılık milli güvenlik meselesi haline geldi

Yükseköğretim Kurulu (YÖK) Başkanı Prof. Dr. Erol Özvar, tarım ve hayvancılığın ekonomik, milli güvenlik ve toplumsal refah açısından kritik bir öneme sahip olduğunu belirterek YÖK ve Tarım Teknolojileri Kümelenmesi Vakfı (TÜME) arasında iş birliği protokolü imzalandığını duyurdu. Protokol ile 40 bin gence istihdam imkanı sağlanması hedefleniyor.

Kripto Varlık Düzenlemeleri Geri Çekildi

AK Parti Genel Başkan Yardımcısı Ömer İleri, TBMM Genel Kurulunda görüşmeleri devam eden ekonomiye ilişkin düzenlemeleri de içeren Bazı Kanunlarda Değişiklik Yapılmasına Dair Kanun Teklifi’nde yer alan kripto varlıklara ilişkin maddelerin, sektördeki hızlı değişim ve gelişmeler göz…