Build Your First AI App with NVIDIA NIM in 30 Minutes

Most students I’ve taught have used ChatGPT. Far fewer have called a model from code.

That is the gap this post is meant to close. In 30 minutes, you’ll call an NVIDIA-hosted language model from Python, pass it a small knowledge base, and make it answer only from that data. No GPU setup, no CUDA detour, no pretending a notebook is production. The goal is simple — write a normal Python program that talks to an LLM and gets useful text back.

I’m B Torkian, NVIDIA Developer Champion, and I use this as a starter workshop for university and community groups. I’ve run a version of it with about 40 students. What usually surprises people is how ordinary the app feels. Most of it is normal software; one function call in the middle just happens to be weirdly powerful.

Everything runs in Google Colab because, for a room full of mixed laptops (I have made peace with this), boring setup wins.

This is Part 1 of a 5-part series that goes from one API call all the way to a small tool-using agent. Each post stands on its own, so start here and move forward as far as you want to go.

What you’re building

User question → Python app → NVIDIA NIM API → LLM response → App output

A small campus assistant. It will call an NVIDIA-hosted Llama model, use the data you provide, and refuse when the answer isn’t there.

That refusal part matters. Demos can guess. Useful apps need to know when to say “I don’t know.”

What NVIDIA NIM is

NIM stands for NVIDIA Inference Microservices. For this post, treat it as hosted model inference from NVIDIA with a clean API in front.

There are two common ways to use it:

  1. Hosted through NVIDIA’s API Catalog at build.nvidia.com. That’s what we’re using here; check the current catalog terms before you teach it, because credits and available models can change.
  2. Self-hosted on your own GPU later, with the same API shape. (That’s Part 4 of this series.)

Whoever decided NVIDIA’s API should mimic OpenAI’s saved everyone a week of onboarding. You use the client most people have already seen, point it at a different endpoint, and move on.

Prerequisites (5 minutes)

  1. A free NVIDIA Developer account — developer.nvidia.com
  2. An API key from build.nvidia.com → pick any model → Get API Key. It starts with nvapi-.
  3. A Google account for Colab.

The first time I taught this, I forgot to say the key starts with nvapi-, and half the room pasted the wrong thing (usually not their fault). Check that before you debug anything else.

Step 1: Open Colab and install the client

NVIDIA’s API Catalog is OpenAI-compatible, so we’ll use the standard openai Python client and point it at NVIDIA’s endpoint.

%pip install -q openai

import os, getpass
from openai import OpenAI

os.environ['NVIDIA_API_KEY'] = getpass.getpass('Paste your NVIDIA API key: ')

client = OpenAI(
    base_url='https://integrate.api.nvidia.com/v1',
    api_key=os.environ['NVIDIA_API_KEY'],
)

MODEL = 'meta/llama-3.1-8b-instruct'

Notice two things:

  • base_url points at NVIDIA’s hosted inference endpoint.
  • MODEL is just a model name from the API Catalog. Swap it later if you want; the call shape does not change.

Step 2: Make your first model call

def ask(system_prompt: str, user_message: str) -> str:
    response = client.chat.completions.create(
        model=MODEL,
        messages=[
            {'role': 'system', 'content': system_prompt},
            {'role': 'user',   'content': user_message},
        ],
        temperature=0.3,
        max_tokens=400,
    )
    return response.choices[0].message.content

print(ask(
    system_prompt='You are a helpful, concise assistant.',
    user_message='Explain GPU acceleration to a first-year CS student in 5 sentences.',
))

Run it.

That ask() function is the basic shape of a lot of AI apps — instructions in, user input in, model response out. Real systems add plumbing, but this is the core.

Step 3: Use the system prompt to steer the model

Now keep the model and change its job description:

print(ask(
    system_prompt='You are a sarcastic but accurate professor. Keep it under 5 sentences.',
    user_message='Explain GPU acceleration to a first-year CS student.',
))

The output changes because the system prompt changes the model’s job. A little precision buys you a lot here; vague prompts make debugging miserable.

Treat prompts like tiny specs — include constraints, output shape, and what to do when a question goes off-track. Then test with slightly annoying questions, because users will absolutely ask those.

Step 4: Build the campus assistant

An LLM doesn’t know your campus schedule. It may still sound confident, which is exactly the problem.

So put the campus information directly into the prompt:

campus_info = """
The AI Club meets every Thursday at 5 PM in the engineering building, room 204.
The GPU computing lab is open Monday to Friday from 10 AM to 6 PM.
Students can join the NVIDIA Developer Program for free.
The next workshop will cover Retrieval Augmented Generation (RAG).
Office hours for the AI/ML faculty are Tuesdays 2-4 PM.
"""

assistant_system_prompt = f"""You are a campus assistant. Answer ONLY using the
information in CAMPUS INFO below. If the answer is not in there, say
"I don't have that information — check with the AI Club."

CAMPUS INFO:
{campus_info}
"""

for question in [
    'When does the AI Club meet?',
    'Is the GPU lab open on Saturday?',
    'What is the wifi password?',
]:
    print(f'Q: {question}')
    print(f'A: {ask(assistant_system_prompt, question)}n')

Run it and read the outputs before moving on. The AI Club answer should come straight from the text. For Saturday, the model often refuses with the fallback line instead of inferring closed. That is the behavior I want for this exercise — “Monday to Friday” gives a human enough to reason about Saturday, but the exact Saturday answer is not stated in the provided data.

The wifi question should also get the fallback line, because there is nothing in campus_info about passwords. If your model says “I don’t have that information — check with the AI Club,” do not treat that as a failure. It stayed inside the box we gave it, which is the whole point.

Last cohort, one student replaced the campus info with their D&D campaign notes and ended up with the most fun bug-hunting session of the day. The pattern works for silly data and useful data, which is why it sticks.

Step 5: What you actually did

You just built manual RAG — you picked the context by hand, inserted it into the prompt, and asked the model to answer from that context. In a production-ish version, the hand-picked campus_info string becomes whatever your retrieval system finds.

In a real app, the context probably comes from PDFs, docs, tickets, lecture notes, or a wiki. You retrieve a few relevant chunks at query time, usually with embeddings and a vector database, then pass only those along.

The model call barely changes — campus_info becomes the output of retrieval. Most of the engineering work lives in that swap.

That swap is exactly what Part 2 of this series is about.

Get the code

Repo: github.com/torkian/nvidia-nim-workshop
One-click Colab: Open the notebook

Next in this series

Part 2: Give Your AI App Real Knowledge — Embedding-Based RAG with NVIDIA NIM. We replace the hand-picked context string with a real retriever that uses NVIDIA’s embedding model, cosine similarity, and a query/passage distinction that most beginners get wrong on the first try.

Contributing to Laravel Maestro Starter Kits Without Losing Your Changes

What is Maestro

Maestro is an upstream generator repository for generating and managing Laravel starter kits.

The following starter kit repositories are downstream repositories generated from Maestro, and directly modifying the starter kit side does not propagate changes upstream.

  • laravel/react-starter-kit
  • laravel/vue-starter-kit
  • laravel/svelte-starter-kit

Therefore, when making changes such as the following, you must modify the maestro side instead.

  • Shared starter kit bug fixes
  • Cross-variant fixes
  • Generator-level specification changes
  • UI consistency adjustments across stacks

Maestro Structure

Unlike a normal Laravel application, Maestro has the following structure.

  • kits/ — source template
  • build/ — generated application (generated runtime)
  • watcher — synchronization layer from build/ → kits/

Inertia Variant Matrix

variant build command starter_kit
React Fortify php artisan build --kit=react react
React WorkOS php artisan build --kit=react --workos react-workos
React Fortify + Teams php artisan build --kit=react --teams react-teams
React WorkOS + Teams php artisan build --kit=react --workos --teams react-workos-teams

The same applies to Vue / Svelte.

If you misunderstand “which side is the source of truth,” unintended rollbacks and unnecessary diffs are very likely to occur.

Basic Principles

kits/ Is the Source of Truth

Any diff that should be committed must exist in kits/. build/ is generated output and should be treated as a working directory for verification, temporary edits, and runtime testing.

You must not commit diffs from the build/ side.

build/ Is Disposable

Treat build/ as something that can always be regenerated. Since its contents change significantly whenever variants are switched, do not place long-term changes, source patches, or persistent fixes directly in build/. Permanent changes belong in kits/.

composer kit:run Is Convenient but Also Dangerous

composer kit:run is a convenience command that launches setup, the Laravel dev server, the Vite dev server, and the watcher together.

However, the watcher treats build/ as authoritative and writes changes back into kits/. As a result, if you start kit:run using an outdated build/, changes in kits/ may get rolled back.

After directly editing kits/, never run composer kit:run with an outdated build/. Always rebuild first.

Initial Setup

git clone https://github.com/laravel/maestro.git
cd maestro/orchestrator

composer install
npm install

Building a Starter Kit

At the beginning of work, expand the target variant into build/.

cd orchestrator

php artisan build --kit=react
php artisan build --kit=vue
php artisan build --kit=svelte

php artisan build --kit=react --workos
php artisan build --kit=react --teams

The current build target is stored at:

orchestrator/storage/app/private/starter_kit

Starting the Development Server

Do not start directly from build/. Instead, run the following from orchestrator/.

composer kit:run

This command performs the following together:

  • composer setup in build/
  • Starts the Laravel dev server
  • Starts the Vite dev server
  • Starts the watcher

Typical endpoints:

  • Laravel: http://localhost:8000
  • Vite: http://localhost:5173

Safe Workflow

Pattern 1: Edit on the build Side (for temporary verification)

build → kit:run → edit build → verify behavior

Convenient for exploratory UI adjustments. However, since build/ is disposable, it is safer to reorganize the final source patch into kits/.

Pattern 2: Edit on the kits Side (recommended path for PRs)

edit kits → php artisan build ... → composer kit:run → verify

This is the recommended approach when preparing PR patches.

Switching Variants

Do not incrementally update an existing build/. Rebuild every time.

php artisan build --kit=react
php artisan build --kit=vue
php artisan build --kit=svelte

After switching, rerun composer kit:run each time for verification.

Verification Notes

DB Initialization

composer setup only runs up to php artisan migrate --force. If seeded data is required, run the following inside build/.

php artisan migrate:fresh --seed

Removing Stale Files from the Watcher

When switching variants, the watcher may determine some source assets are stale and delete them.

Examples:

  • kits/Inertia/Fortify/React/chisel-paths.php
  • kits/Inertia/Fortify/Vue/chisel-paths.php
  • kits/Inertia/Fortify/Svelte/chisel-paths.php

Handle these separately from the main change set.

Example Routes for Verifying UI Copy Changes

For UI text changes in the Fortify variant, the following routes are usually sufficient to verify the primary flows.

  • /login
  • /user/confirm-password
  • /verify-email
  • /settings/security
  • /settings/appearance
  • /settings/profile

Since WorkOS has a higher verification cost, prioritizing Fortify variants is acceptable for minor changes.

How to Think About Staging

If the PR’s main purpose is a UI copy change, then only stage the text-related diffs.

The following should generally be separated:

  • chisel-paths.php deleted by the watcher
  • Temporary fixes in build/
  • Assertions for browser tests
  • Unrelated runtime bug fixes
# Instead of git add ., explicitly add target files
git add kits/path/to/changed-file.blade.php

Pre-PR Checklist

  • [ ] Are there any staged diffs from build/?
  • [ ] Are stale deletions from the watcher mixed in?
  • [ ] Are there unnecessary cross-variant diffs?
  • [ ] Are temporary browser test changes still present?
  • [ ] Did you check git diff --cached?
  • [ ] Is the source of truth correctly on the kits/ side?

Summary

If you handle Maestro like a normal Laravel app, diff rollbacks and unnecessary commits become very easy to trigger. To create PRs safely, maintain the following sequence.

Safe PR Flow

  1. Apply changes in kits/
  2. Rebuild with php artisan build ...
  3. Verify with composer kit:run
  4. Stage only intended diffs
  5. Separate watcher/browser-test side effects

Git Advanced: The Commands I Wish I Knew Earlier (2026)

Git Advanced: The Commands I Wish I Knew Earlier (2026)

Beyond add, commit, push. These are the Git commands that actually save time.

The Basics (Quick Refresher)

git status          # What changed?
git diff             # What exactly changed?
git log --oneline   # Recent commits
git add -A           # Stage everything
git commit -m "msg"  # Commit staged changes
git push origin main # Send to remote

You know these. Let’s go further.

Undo Everything (Safely)

# Unstage a file (keep changes)
git reset HEAD path/to/file

# Discard all unstaged changes in a file
git checkout -- path/to/file
# Or newer syntax:
git restore path/to/file

# Discard ALL unstaged changes
git checkout -- .
git restore .

# Uncommit (keep changes, remove commit)
git reset --soft HEAD~1    # Changes stay staged
git reset HEAD~1           # Changes stay unstaged
git reset --hard HEAD~1    # Changes GONE forever ⚠️

# Amend last commit (fix message or add forgotten file)
git commit --amend -m "Better message"
git add forgotten-file.txt
git commit --amend         # Add file to previous commit

Interactive Rebase: Clean Up History

# Edit/reorder/squash last 5 commits
git rebase -i HEAD~5

# Opens editor with:
pick abc1234 First commit
pick def5678 Second commit
pick ghi9012 Third commit
pick jkl3456 Fourth commit
pick mno7890 Fifth commit

# Change 'pick' to:
# reword  = edit commit message
# edit    = pause to amend files
# squash  = merge into previous commit
# fixup   = merge into previous (discard this message)
# drop    = remove commit entirely
# exec    = run shell command

# Example: Squash the last 2 commits into one
pick abc1234 First commit
squash def5678 Second commit
# → Creates single commit with combined changes

Stashing: Save Work Without Committing

# Save current work temporarily
git stash
git stash push -m "WIP: feature X"

# List all stashes
git stash list
# stash@{0}: On main: WIP: feature X
# stash@{1}: On main: bugfix attempt

# Restore most recent stash
git stash pop      # Apply + remove from list
git stash apply    # Apply + keep in list

# Restore specific stash
git stash apply stash@{1}

# Drop a stash
git stash drop stash@{0}

# Clear all stashes
git stash clear

# Stash only some files (not everything)
git stash push -m "partial" src/utils.js src/config.js

# Stash with untracked files included
git stash push -u -m "include new files"

Searching Through History

# Search commit messages
git log --grep="bug fix" --oneline
git log --grep="auth" --since="2026-01-01"

# Search code content across all history
git log -S "functionName" --oneline     # Added/removed this string
git log -G "pattern" --oneline          # Changed matching this regex
git log -p -S "TODO"                    # Show actual diffs too

# Find which commit introduced a bug
git bisect start
git bisect bad                          # Current version has bug
git bisect good v1.0.0                  # This version was fine
# Git will check out middle commit → you test → mark good/bad
# Repeat until it finds the exact bad commit
git bisect reset                        # When done

# Search current working tree
git grep "search term"                  # In tracked files
git grep "search term" $(git rev-list --all)  # Across ALL history!

Branching Strategies That Work

Feature Branch Workflow

# Start a new feature
git checkout -b feature/user-auth
# Or:
git switch -c feature/user-auth

# Make commits...
git add -A && git commit -m "Add login page"

# Update your branch with latest main
git fetch origin
git rebase origin/main        # Cleaner than merge for PRs

# Push branch (first time needs -u)
git push -u origin feature/user-auth

# After PR is merged, clean up
git branch -d feature/user-auth              # Delete local
git push origin --delete feature/user-auth   # Delete remote

Quick Branch Management

# See all branches (local + remote)
git branch -a

# Rename current branch
git branch -m old-name new-name

# Rename remote branch (after local rename)
git push origin :old-name new-name

# Switch to previous branch
git checkout -
# Or: git switch -

# Compare branches
git log main..feature-branch --oneline    # Commits in feature but not main
git diff main...feature-branch            # Diff between branch tips

# Merge vs Rebase summary:
# git merge feature   → Creates merge commit, preserves exact history
# git rebase feature  → Linear history, rewrites commits (cleaner for PRs)

Dealing with Remote Changes

# Fetch all remotes (doesn't merge)
git fetch --all

# Pull = fetch + merge
git pull origin main

# Pull with rebase (avoids extra merge commits)
git pull --rebase origin main

# Force push (⚠️ dangerous on shared branches!)
git push --force-with-lease origin feature-branch
# Safer than --force: checks if remote changed first

# Remove remote tracking branch that was deleted
git prune
git fetch --prune                         # Fetch + cleanup stale refs

Cherry-Picking: Grab Specific Commits

# Apply one commit from another branch
git cherry-pick abc1234

# Apply multiple commits
git cherry-pick abc1234 def5678 ghi9012

# Cherry-pick without committing (edit first)
git cherry-pick -n abc1234
# Make changes, then:
git commit -m "Modified cherry-pick"

# Cherry-pick from another repo (as patch)
git format-patch -1 abc1234 --stdout > /tmp/patch.git
git am < /tmp/patch.git

Fixing Mistakes in Committed Code

# Forgot to include a file in last commit?
git add forgotten-file.js
git commit --amend --no-edit

# Need to change an older commit? Use interactive rebase:
git rebase -i HEAD~5
# Change 'pick' to 'edit' on the target commit
# Make your changes
git add fixed-file.js
git rebase --continue

# Split a commit into two:
git rebase -i HEAD~3
# Change 'pick' to 'edit' on target commit
git reset HEAD~1                # Uncommit while keeping changes
git add file-a.js               # Stage part of changes
git commit -m "Part A: file A"
git add file-b.js               # Stage rest
git commit -m "Part B: file B"
git rebase --continue

Understanding .gitignore

# Patterns
node_modules/                   # Directory
*.log                           # All .log files
!.important.log                 # Except this one
build/                          # Build output
.env                            # Secrets (NEVER commit!)
.DS_Store                       # macOS junk
.vscode/                        # Editor config (optional)

# Global ignore (for all repos)
git config --global core.excludesfile ~/.gitignore_global

# Debug what's being ignored
git check-ignore -v node_modules/debug.log
git status --ignored            # Show ignored files too

# Track a file that's already .gitignored
git add -f important.log        # Force add (-f overrides ignore rules)

# Stop tracking a file (but keep locally)
git rm --cached large-file.dat
# Then add to .gitignore

Useful Aliases

# Save typing with aliases
git config --global alias.co checkout
git config --global alias.br branch
git config --global alias.ci commit
git config --global alias.st status
git config --global alias.lg "log --graph --oneline --decorate -20"
git config --global alias.unstage "reset HEAD --"
git config --global alias.last "log -1 --stat"
git config --global alias.amend "commit --amend --no-edit"
git config --global alias.cleanup "!git branch --merged | grep -v '\*\|main\|develop' | xargs git branch -d"
git config --global alias.discard "checkout --"

# Now use:
git co feature-branch       # = git checkout
git lg                      # Beautiful graph log
git cleanup                 # Delete merged branches
git discard filename        # Discard changes to file

Quick Reference Card

Task Command
Undo last commit (keep files) git reset --soft HEAD~1
Undo last commit (lose files) git reset --hard HEAD~1
Amend last commit git commit --amend
Save work temporarily git stash / git stash pop
Edit recent commits git rebase -i HEAD~N
Find commit that added text git log -S "text"
Compare branches git diff main...feature
Clean merged branches `git branch –merged
Safe force push {% raw %}git push --force-with-lease
Grab commit from other branch git cherry-pick <sha>
Bisect to find bug commit git bisect start/good/bad/reset

Which Git command saved you the most time?

Follow @armorbreak for more practical developer guides.

Warum wir eine eigene KYC-API gebaut haben — und es kostenlos rausgeben

Das Problem: KYC ist teuer. Sehr teuer.

Vor knapp einem Jahr hatten wir bei Enjyn ein wiederkehrendes Muster: Für mehrere unserer Kundenprojekte brauchten wir eine zuverlässige Identitätsprüfung — mal für Altersverifikation, mal für eine Community-Plattform, mal für einen Marktplatz mit Verkäufer-KYC.

Wir haben uns die üblichen Verdächtigen angeschaut:

Anbieter Preis pro Verifikation Setup
Onfido ab ~2 € Enterprise-Sales
IDnow ab ~2–5 € Vertrieblicher Erstkontakt
Veriff ab ~1–3 € Self-Service, aber Mindestvolumen

Bei einem unserer Projekte mit erwarteten 1.500 Verifikationen im Monat wären das 2.250 – 7.500 € monatlich gewesen. Für ein Tool, das vor allem im Hintergrund läuft und „nur” prüft, ob ein Personalausweis echt ist.

Die ehrliche Erkenntnis: Wir wollten das nicht zahlen. Und unsere Kunden auch nicht.

Die Frage: Können wir das selbst bauen?

Ein paar Wochenenden Recherche später war klar: Ja, können wir. Die Bestandteile sind technisch alle vorhanden:

  • Dokumenten-OCR für Ausweisdaten (Name, Geburtsdatum, MRZ-Zone)
  • Dokumenten-Authentizitätsprüfung (Hologramme, Sicherheitsmerkmale, MRZ-Checksums)
  • Datenabgleich zwischen Nutzer-Eingabe und Ausweisdaten
  • Auto-Datenextraktion aus Vorder- und Rückseite

Was wir nicht wollten:

  • Daten in die USA schicken (DSGVO-Albträume mit Schrems II)
  • Cloud-Lösungen mit Per-Call-Pricing (genau das Problem, das wir lösen wollten)
  • Black-Box-Modelle, deren Funktion wir nicht erklären können

Also haben wir uns hingesetzt und unsere eigene KI/ML-Pipeline gebaut. Komplett in-house, auf eigenen Servern in Deutschland.

Der Stack — grob umrissen

Aus naheliegenden Gründen veröffentlichen wir nicht jedes Detail (Anti-Fraud-Schutz), aber das große Bild:

Bildverarbeitung: Ausweis-Bilder werden zuerst normalisiert, entzerrt und auf Qualität geprüft. Zu unscharf, zu schräg oder Bildschirm-Fotografie → automatische Ablehnung.

OCR & MRZ-Parsing: Die Maschinenlesbare Zone (MRZ) deutscher Ausweise und Reisepässe wird ausgelesen, validiert (Prüfsummen) und mit den OCR-Daten aus dem visuellen Bereich abgeglichen.

Datenabgleich: Die vom Anwender vorab angegebenen Daten (Vorname, Nachname, Geburtsdatum) werden mit den extrahierten Ausweisdaten verglichen. Diskrepanzen → Status failed.

Auto-Löschung: Hochgeladene Bilder werden nach 10 Minuten gelöscht, Satus-Daten nach 30 Tagen. Mehr nicht.

Wie die API aussieht

Wir nutzen einen einfachen, dreistufigen Flow — bewusst minimalistisch gehalten:

1. Verifikations-Session erstellen

curl -X POST https://api.verify.enjyn.de/api/v1/verify 
  -H "Content-Type: application/json" 
  -H "X-API-Key: YOUR_API_KEY" 
  -d '{
    "vorname": "Max",
    "nachname": "Mustermann",
    "geburtsdatum": "01.01.1990",
    "return_url": "https://ihre-website.de/callback.html"
  }'

Response:

{
  "success": true,
  "session_id": "uuid",
  "token": "secure-token",
  "verify_url": "https://api.verify.enjyn.de/verify/token",
  "qr_url": "https://api.verify.enjyn.de/api/v1/qr-code/token"
}

Du leitest den Nutzer auf die verify_url weiter — oder zeigst den QR-Code an, damit er die Verifikation auf dem Smartphone fortsetzt. Der Nutzer lädt dort Vorder- und Rückseite seines Ausweises hoch, der Rest passiert automatisch.

2. Status nach Callback abrufen

Nach Abschluss leiten wir den Nutzer auf deine return_url weiter. Wichtig: Der status-Parameter in der URL ist nicht vertrauenswürdig (kann manipuliert werden). Den echten Status holst du dir per API:

curl https://api.verify.enjyn.de/api/v1/get-result/{session_id} 
  -H "X-API-Key: YOUR_API_KEY"

Response bei Erfolg:

{
  "success": true,
  "status": "verified",
  "vorname": "Max",
  "nachname": "Mustermann",
  "geburtsdatum": "01.01.1990",
  "verified_at": "2026-05-21T23:42:18Z"
}

3. Vollständige JavaScript-Integration

// 1. Session erstellen
const response = await fetch('https://api.verify.enjyn.de/api/v1/verify', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-API-Key': 'YOUR_API_KEY'
    },
    body: JSON.stringify({
        vorname: 'Max',
        nachname: 'Mustermann',
        geburtsdatum: '01.01.1990',
        return_url: 'https://ihre-website.de/callback.html'
    })
});

const { verify_url, session_id } = await response.json();

// Session-ID lokal merken für späteren Abruf
sessionStorage.setItem('kyc_session', session_id);

// 2. Nutzer zur Verifikation weiterleiten
window.location.href = verify_url;

// 3. Auf der Callback-Seite: Status SICHER validieren
const sessionId = sessionStorage.getItem('kyc_session');
const result = await fetch(
    `https://api.verify.enjyn.de/api/v1/get-result/${sessionId}`,
    { headers: { 'X-API-Key': 'YOUR_API_KEY' } }
);
const verification = await result.json();

if (verification.status === 'verified') {
    console.log('✓ Verifiziert:', verification.vorname);
}

Status-Werte

Status Beschreibung
pending Wartet auf Nutzer-Upload
processing Bilder werden verarbeitet
verified ✅ Erfolgreich
failed ❌ Fehlgeschlagen

Was wir bewusst NICHT machen

Keine internationalen Ausweise (noch nicht). Wir prüfen aktuell deutsche Personalausweise und Reisepässe. Das deckt unseren Use Case ab. Internationale Dokumente erfordern Trainingsdaten und Validierung, die wir bisher nicht haben.

Kein BaFin-Level KYC. Für Banken und Finanzdienstleister gibt es regulatorische Anforderungen (qualifizierte elektronische Signatur, Video-Ident mit Schulungsanforderungen), die wir nicht abdecken. Wenn du als Bank KYC brauchst: nimm IDnow oder PostIdent. Wir sind die richtige Wahl für E-Commerce, Communities, SaaS, Events und Marktplätze.

Kein Speichern der Originaldaten über Wochen. Manche Anbieter behalten Dokumente monatelang für „Audit-Zwecke”. Wir nicht. Bilder weg nach 10min, Session (anonymisiert) weg nach 30 Tagen.

Warum wir es kostenlos rausgeben

Faire Frage. Antwort in drei Teilen:

Erstens — Eigeninteresse. Wir nutzen es selbst in mehreren Projekten. Es kostenlos verfügbar zu machen, kostet uns kaum mehr als es nur intern zu nutzen. Die Infrastruktur läuft sowieso.

Zweitens — Marktposition. Wir sind eine Software-Agentur. Kunden, die unser KYC-API ausprobieren und damit zufrieden sind, fragen uns irgendwann auch für andere Dinge an. Hosting, Custom-Entwicklung, SEO. Das funktioniert.

Drittens — wir können Volumen-Preise später noch differenzieren. Aktuell ist es bis zu 150 Verifys (nur erfolgreich) Monatlich kostenlos.

Aktuelle Nutzung

Wir haben aktuell 8 aktive Partner, die zusammen 60–150 Verifikationen pro Monat durchführen. Klein, aber stetig wachsend. Wir wollten erst sicher sein, dass die Pipeline robust läuft, bevor wir lauter werden.

Das hier ist der Anfang davon, lauter zu werden.

Wenn du es probieren willst

  • Demo: enjyn.de/kyc — du kannst dich live durchprüfen lassen
  • API-Docs: enjyn.de/apis?kyc
  • API-Key anfordern: Kurze Nachricht über enjyn.de/kontakt mit deinem Use Case. Wir machen das aktuell noch manuell, um Missbrauch zu verhindern. Antwortzeit meist unter 6h.

The Scoped Singleton DI Bug Your AI Just Suggested

The Scoped→Singleton DI bug your AI just suggested (and how to catch it)

Of all the bugs that ship to production silently, the captured-dependency lifetime bug is one of the most expensive. It compiles. It passes your tests. It runs fine in dev. Then in production, under load, it starts corrupting data across requests. And AI assistants suggest it constantly. Here’s why — and the one Cursor rule that catches it before merge.

The bug, in 30 lines

“
You ask Cursor to add caching to OrderService. It gives you this:

`plaintext

`
// OrderService.cs
public class OrderService : IOrderService
{
private readonly IMemoryCache _cache;
private readonly OrderDbContext _db;

public OrderService(IMemoryCache cache, OrderDbContext db)
{
    _cache = cache;
    _db = db;
}

public async Task<Order?> GetAsync(int id, CancellationToken ct)
{
    if (_cache.TryGetValue(id, out Order? cached)) return cached;

    var order = await _db.Orders.FindAsync(new object[] { id }, ct);
    if (order is not null) _cache.Set(id, order, TimeSpan.FromMinutes(5));
    return order;
}

}

// Program.cs
builder.Services.AddDbContext(…);
builder.Services.AddScoped();
builder.Services.AddMemoryCache(); // ← registers IMemoryCache as Singleton

Looks correct. Compiles. Tests pass. Shipped.

What actually happens at runtime

********
IMemoryCache is registered as Singleton — one instance for the entire app’s lifetime. OrderService is registered as Scoped — one instance per HTTP request.

plaintext
On its own, that’s fine. The problem is what you cached: an Order entity, which is in turn attached to OrderDbContext — also Scoped. The cache, alive for the lifetime of the application, now holds a reference to an entity attached to a DbContext that was disposed when the original request ended.

“
Now request #2 comes in. It hits the cache, gets the order, mutates a property. Then request #3 hits the cache, sees the mutation, and decides to write something else based on it. Then request #4 wakes up the entity’s dispose-tracking and explodes with ObjectDisposedException — but only sometimes, depending on the GC pressure that day.

Welcome to the longest debugging session of your year.

Why AI assistants suggest this constantly

The patterns the AI has seen most often in its training data — short examples, blog tutorials, StackOverflow answers — almost always omit DI registration. A typical “caching with IMemoryCache” snippet looks like ten lines, with no reference to where the service is registered or with what lifetime.

The AI learned the surface pattern (“inject IMemoryCache, call .Set”) without the surrounding constraint (“…unless the consumer is Scoped and the cached value graph reaches into Scoped infrastructure”). When you ask it to add caching to your codebase, it pattern-matches against the surface form. The constraint is invisible to it.

This isn’t a “the AI is dumb” critique. Most senior developers ship this exact bug at least once. The patterns in the wild teach the wrong lesson.

The five lifetime traps to teach the AI

If you’re going to enforce one set of rules on AI-suggested .NET code, make it these:

  1. Scoped or Transient injected into Singleton

“
The classic. A Singleton constructor takes IRepository (Scoped). The Singleton captures it forever. Requests share state. Data corrupts.

The rule: when adding a constructor parameter, check the parameter type’s registered lifetime. If the consumer is Singleton and the parameter is Scoped/Transient, refuse and surface the issue.

  1. DbContext captured by anything Singleton

****
Special case of #1 but worth its own callout. DbContext is always Scoped — it has to be, it tracks per-request state. Any Singleton that captures a DbContext is a bug. If you need DB access from a Singleton, inject IServiceScopeFactory and create a scope per operation.

  1. Cached entities still attached to a DbContext

The bug from the example. The cache outlives the DbContext, but holds a graph that depends on it.

****“
The rule: what goes into long-lived caches must be either (a) AsNoTracking()’d, (b) projected to a DTO, or (c) detached explicitly.

4. HttpClient instantiated with new


A long-running app that does new HttpClient() on every call leaks sockets — eventually exhausting the connection pool. Even worse: a Singleton that captures a single HttpClient reuses DNS forever.

The rule: always inject IHttpClientFactory and call CreateClient(name). Never new HttpClient() outside of one-shot scripts.


### 
5. Hosted services touching Scoped dependencies directly


``
IHostedService is Singleton-by-construction. Inject a Scoped repo into one and it'll be alive for the lifetime of the process — every "scoped" operation will share state. Worse, the DbContext will leak.


****``````


The rule: in any BackgroundService or IHostedService, never inject Scoped dependencies directly. Inject IServiceScopeFactory and create a scope per unit of work.


## 
The Cursor rule that catches all five


``[](https://agenticstandardcontact-byte.github.io/agentic-architect/)``

````
The dotnet-di.mdc rule in Agentic Architect codifies the above. When Cursor is editing a file where DI is happening — Program.cs, Startup.cs, ServiceCollectionExtensions.cs, any class constructor — the rule activates and audits suggestions for:


- Lifetime mismatches between consumer and constructor parameters

- Captured Scoped dependencies inside hosted services or background workers

- ``

Direct HttpClient instantiation

- Captured tracked entities in long-lived caches

- Static helpers reaching into scoped infrastructure



**
The trick is the scoping: it loads only on files where DI is actually happening — not on every prompt. Your token budget stays sane. The AI stays sharp on the file you're actually in.


## 
The bigger pattern: enforce, don't suggest


********
The reframe that took me a year of using AI assistants to internalize is this: generic prompts ask the AI to suggest good patterns. Scoped rules force it to enforce them.


"Be careful with DI lifetimes" is a suggestion. The AI will agree, nod sagely, then ship the captured-Scoped bug an hour later when you're tired.


"Before suggesting any constructor change, audit the lifetime contract" is a rule. The AI now has a checklist. It pauses, runs the check, and either suggests a boundary-respecting alternative or asks you a targeted question — instead of confidently shipping the bug.


**
The first time the AI catches a Scoped-into-Singleton in code you wrote, the kit pays for itself.

---

*Originally published at [https://agenticstandardcontact-byte.github.io/agentic-architect/blog/02-scoped-singleton-di-bug.html](https://agenticstandardcontact-byte.github.io/agentic-architect/blog/02-scoped-singleton-di-bug.html). Part of the [Agentic Architect](https://agenticstandardcontact-byte.github.io/agentic-architect/) persistence kit for Cursor + .NET.*

I Built a Private AI Assistant That Queries My Git History and Project Management Data — Using Only Local LLMs

No API keys. No cloud. All data stays on my machine.

The Problem

As a web developer, I constantly need to answer questions like:

  • “Who committed the most to our main repo this month?”
  • “What files were changed for the last campaign launch?”
  • “What project tasks are still in progress for the web team?”

These answers exist — scattered across git log, project management boards, and my own memory. I was tired of digging through terminal output and clicking through boards manually.

So I built a natural language interface that lets me ask these questions in plain English and get instant answers.

The Architecture: Text-to-SQL, Not Vector RAG

Here’s the key insight that shaped the entire project:

My data is structured, not unstructured. Commits have authors, dates, and repos. Project tasks have statuses, deadlines, and assignees. This isn’t a pile of PDFs — it’s relational data that fits naturally into a SQLite database.

Traditional RAG (vector embeddings + similarity search) is built for unstructured documents. For structured data, there’s a better approach: Text-to-SQL.

User Question
    ↓
Local LLM (generates SQL)
    ↓
SQLite Database (executes query)
    ↓
Local LLM (summarizes results)
    ↓
Human-readable Answer

The LLM doesn’t store or memorize my data. It just translates my question into SQL, runs it, and explains the results.

The Data Pipeline

Step 1: Collect everything into SQLite

I wrote two Python collectors that populate a single SQLite database:

Git history collector (collect.py):

  • Runs git log across multiple repositories
  • Stores commits, file changes, branches, and tags
  • Captures author, date, message, and insertions/deletions per file

Project management collector (collect_pm.py):

  • Queries the project management platform’s GraphQL API (Monday.com in my case, but the pattern works for Jira, Linear, etc.)
  • Stores boards, items, and subitems
  • Extracts status, assignee, department, and deadline
  • Flags web-team tasks automatically (is_web = 1)

The result: a single SQLite database holding everything needed to answer cross-cutting questions.

Step 2: Link git branches to project tasks

This was the crucial step. Git branches like feature/example-promo-banner don’t obviously connect to project items like “Example Promo Banner — Launch”.

I created a branch_task_map table that links them:

SELECT branch_name, task_name, board_name
FROM branch_task_map
WHERE branch_name LIKE '%promo-banner%'

This lets the system cross-reference: “What tasks relate to this branch?” or “What commits were made for this launch?”

The RAG System

Why Ollama?

Privacy was non-negotiable. Project data, commit messages, and task details shouldn’t leave the machine. Ollama runs the LLM entirely locally — no internet needed, no data sent anywhere.

I chose qwen2.5-coder:7b as the model — it’s excellent at SQL generation and runs fast on Apple Silicon.

The smart prompt

The system prompt is where the magic happens. It includes:

  1. Full database schema — auto-introspected at startup
  2. Sample values — actual repo names, anonymized author identifiers, statuses from the database
  3. Few-shot SQL examples — teaches the model the query patterns
  4. Today’s date — so “this week” and “last month” work correctly
def build_system_prompt():
    schema = get_schema()         # Auto-introspect SQLite tables
    samples = get_sample_values() # Real values from the DB
    return f"""You are a data analyst assistant...

## Database Schema
{schema}

## Sample Values
{samples}
..."""

Auto-discovery: the secret sauce

Before the LLM even sees the question, the system extracts keywords and searches across all tables:

def discover(question):
    keywords = extract_keywords(question)
    # Search task_boards, task_items, commits, branches...
    # Return matching IDs, names, values

This means when you ask “What’s happening with the example promo banner launch?”, the system has already found:

  • The matching project board
  • Related branches: feature/example-promo-banner
  • Recent commits referencing the same keywords

The LLM gets these exact values, so it writes precise SQL instead of guessing.

Self-correcting queries

If a SQL query returns 0 results, the system automatically retries with different keyword strategies:

Attempt 1: WHERE branch = 'feature/example-promo-banner'  → 0 results
Attempt 2: WHERE message LIKE '%promo banner%'             → 12 results

This handles the reality that commits are often on parent branches, not the feature branch itself.

The Result

A CLI tool where I type questions and get answers:

$ python3 main.py "who committed the most this month?"

Developer A and Developer B lead this month
with roughly 350 commits each, followed by
Developer C with around 280 commits.
$ python3 main.py "what web tasks are pending for the next launch?"

The upcoming launch has 8 web tasks remaining:
3 in progress, 2 ready for review, 3 not started...
$ python3 main.py -v "what files changed for the example promo banner?"

-- SQL: SELECT DISTINCT fc.file_path, SUM(fc.insertions)...
-- WHERE c.message LIKE '%promo banner%'...

Several template and snippet files were modified,
concentrated in the promo banner section and a few
related shared components.

Project Structure

The entire system is 8 files, ~400 lines of code, 2 dependencies:

custom-rag/
  main.py           # CLI entry point — REPL + one-shot mode
  agent.py          # LLM conversation loop (question → SQL → answer)
  db.py             # SQLite read-only, schema introspection, auto-discovery
  prompts.py        # System prompt with schema + few-shot examples
  tools.py          # Tool definitions
  formatter.py      # Rich terminal output
  config.py         # Paths and model settings
  requirements.txt  # rich, requests (that's it)

No LangChain. No vector database. No embeddings. No cloud services.

Key Takeaways

  1. Not all RAG needs vectors. If your data is structured, Text-to-SQL is simpler and more accurate than embedding everything into a vector store.
  2. Local LLMs are production-ready. Ollama + qwen2.5-coder:7b runs fast on a MacBook and generates correct SQL reliably.
  3. Auto-discovery beats prompt engineering. Instead of hoping the LLM guesses the right table values, search the database first and feed it exact matches.
  4. Privacy and simplicity can coexist. The whole system is a few hundred lines of Python, runs offline, and handles real questions.
  5. Cross-referencing is the real value. Any single data source is easy to query manually. The power comes from connecting git history with project management data in one natural language interface.

What’s Next

  • Cron job to auto-refresh data every hour
  • Adding chat message history as a third data source
  • A simple web UI for non-terminal users
  • Open-sourcing a generic version of the tool

Built with Python, SQLite, Ollama, and qwen2.5-coder. All code runs locally — no data leaves the machine. All examples in this article use illustrative names and rounded figures; real commit authors, project codenames, and counts have been replaced or generalized.

If you’re interested in the implementation details or want to build something similar, feel free to reach out or comment below.

Multica: An Open-Source Platform for Managing AI Coding Agents Like Teammates

If you’ve been using Claude Code, Codex, or similar AI coding agents, you’ve probably felt the friction: you paste a prompt, watch the run, babysit the output, copy something into the next prompt, and repeat. It works, but it doesn’t scale — and it definitely doesn’t feel like working with a team.

Multica is an open-source project that tries to fix that. The pitch is simple: treat your AI agents the way you treat human teammates. Assign them issues. Watch them post updates. Let them report blockers. Have them compound skills over time.

What Multica Actually Does

At its core, Multica gives your coding agents a place to live inside your team’s workflow. Instead of operating a chatbot in isolation, you assign tasks to an agent the same way you’d assign a GitHub issue to a colleague. The agent picks it up, executes it on a runtime (your local machine or a cloud instance), streams progress back in real time, and posts comments when it needs clarification or hits a wall.

A few things stand out:

Task lifecycle management. Tasks move through states: enqueue, claim, start, complete, or fail. You’re not just running a command and hoping — you have visibility into where each task is.

Reusable skills. When an agent solves something well — a deployment script, a migration pattern, a code review checklist — that solution becomes a reusable skill the whole team can pull from. Skills accumulate over time, which is where the “compound” part of the tagline comes from.

Multi-agent, multi-workspace. You can have multiple agents running on different runtimes, organized into workspaces. Each workspace is isolated with its own issues, agents, and settings.

Vendor-neutral. Multica works with Claude Code, Codex, OpenCode, OpenClaw, Hermes, Gemini, Pi, and Cursor Agent. You’re not locked into one provider.

The Architecture in Plain Terms

The stack is straightforward:

┌──────────────┐     ┌──────────────┐     ┌──────────────────┐
│   Next.js    │────>│  Go Backend  │────>│   PostgreSQL     │
│   Frontend   │<────│  (Chi + WS)  │<────│   (pgvector)     │
└──────────────┘     └──────┬───────┘     └──────────────────┘
                            │
                     ┌──────┴───────┐
                     │ Agent Daemon │  runs on your machine
                     └──────────────┘
  • Frontend: Next.js 16 with the App Router
  • Backend: Go with the Chi router, sqlc for type-safe queries, and gorilla/websocket for real-time streaming
  • Database: PostgreSQL 17 with pgvector (for skill embeddings and similarity search)
  • Agent runtime: A local daemon that auto-detects whatever agent CLIs you have on your PATH

The daemon is the key piece. It bridges your machine (where the actual agent CLI lives) with the Multica server (cloud or self-hosted). When an agent is assigned a task, the server routes it to the appropriate daemon, which spawns the CLI process and streams output back via WebSocket.

Getting Up and Running

Installation is one line:

macOS / Linux:

brew install multica-ai/tap/multica
# or without Homebrew:
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash

Windows:

irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex

Then connect everything:

multica setup   # authenticate + start the daemon in one command

After that, open the web app, go to Settings → Runtimes, and you should see your machine listed. From there you create an agent (pick a provider and runtime), and you’re ready to assign tasks.

The CLI surface is minimal:

Command What it does
multica setup One-shot: configure, authenticate, start daemon
multica daemon start Start the local runtime manually
multica daemon status Check what’s running
multica issue list List your workspace issues
multica issue create Create a new issue
multica update Pull the latest version

If you want to self-host the whole thing (server included), add --with-server to the install script:

curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
multica setup self-host

This pulls the official Docker images from GHCR. You’ll need Docker. The full self-hosting guide lives in SELF_HOSTING.md in the repo.

For Contributors

The dev setup is a single command:

make dev

It auto-detects your environment, sets up the .env file, installs dependencies, runs DB migrations, and starts all services. Prerequisites: Node.js v20+, pnpm v10.28+, Go v1.26+, and Docker.

The codebase is about 53% TypeScript and 43% Go — frontend and backend are clearly separated, which makes it easy to work on one without touching the other.

Multica vs Going Solo With an Agent CLI

Here’s an honest comparison of what Multica adds versus just running claude or codex directly:

What you gain:

  • A shared board where your whole team sees what agents are working on
  • Real-time streaming progress instead of waiting for a long CLI run to finish
  • A skills library that accumulates team knowledge rather than living in individual prompts
  • Multi-agent routing — different tasks can go to different agents on different machines
  • An audit trail: who assigned what, when, and what happened

What you take on:

  • Running a daemon process (or a full server if self-hosting)
  • A PostgreSQL database
  • The overhead of a web app and task board

If you’re a solo developer running occasional agent tasks, the CLI alone might be enough. If you’re on a team — even a small one — trying to coordinate multiple agents across projects, Multica addresses a real gap.

Is It Worth Trying?

It depends on where you are with AI agents.

If you’re still experimenting and running agents manually on single tasks, Multica is probably more infrastructure than you need right now. Start with the agent CLI directly and see what breaks.

If you’ve gotten past that point and are starting to feel the coordination pain — agents running on different machines, teammates not knowing what’s been automated, the same solutions being re-invented in different prompts — that’s exactly the gap Multica is designed to fill.

It’s early software (v0.2.x as of this writing), so expect rough edges. But the core loop — assign, execute, report, reuse — is working, and the momentum behind the project is real.

Links:

The Death of the Junior Developer

Somewhere right now, a hiring manager is looking at a requisition for a junior developer and asking a question that did not exist two years ago: why would I hire someone to do what my senior developers and their AI tools already handle?

Across the industry, a CS graduate is staring at a job board that looks nothing like the one their professors described. Entry-level postings are down 60% since 2022. The career path they were promised has a gap where the first rung used to be.

Both of them are right to be concerned. Neither of them has a good answer.

The Numbers Are Not Subtle

A Harvard study tracking 62 million workers across 285,000 U.S. firms found that companies adopting generative AI cut junior employment by 7 to 12 percent within six quarters. Senior employment stayed flat. The decline was driven by slower hiring, not layoffs. The juniors were not fired. They were never hired in the first place.

Stanford’s Digital Economy Lab found that employment for developers aged 22 to 25 dropped roughly 20% from its late-2022 peak. CS graduate unemployment sits at 6.1%. For ages 22 to 27, it is 7.4%, nearly double the national average.

Big Tech junior hiring fell from 32% of new hires in 2019 to 7% today. Salesforce halted new engineer hiring entirely. Klarna froze developer recruitment. A LeadDev survey found that 54% of engineering leaders plan to hire fewer juniors going forward. AI was the reason given every time.

The math is straightforward. If a senior developer with AI tools produces the output of a senior plus two juniors, the two juniors do not get hired. That is not ideology. It is a spreadsheet.

Camp One: Juniors Are Dead Weight

The efficiency camp does not enjoy saying this out loud, but they are thinking it. A senior engineer with Copilot, Cursor, or Claude Code ships more code in a week than a junior ships in a month. The code is better. It needs less review. It has fewer bugs. The senior already knows the architecture, the edge cases, the patterns. The AI compresses the implementation. The junior was the implementation.

Dario Amodei, the CEO of Anthropic, said it plainly: entry-level jobs are squarely in the crosshairs. He predicted AI could eliminate 50% of entry-level white-collar jobs within five years. He said leaders were sugar-coating what was coming.

Companies report 40 to 55 percent more code output per sprint after adopting AI coding tools. The velocity gains are real. The developer grind that produces them is relentless. And most of that velocity came from eliminating the work that juniors used to do: the boilerplate, the CRUD endpoints, the straightforward implementations that a senior would spec and a junior would type.

This camp will tell you that the market is correcting. That the industry over-hired juniors for years because implementation was expensive. That AI made implementation cheap. That hiring someone to do cheap work at expensive wages is not sustainable. That this is not cruel. It is math.

Camp Two: We Are Eating Our Seed Corn

The pipeline camp has one question the efficiency camp cannot answer: where do future seniors come from?

Matt Garman, the CEO of AWS, called replacing juniors with AI one of the dumbest things he has ever heard. His argument: they are your cheapest employees, your most AI-engaged learners, and if you stop hiring them now, you will have nobody who learned anything when you need senior engineers ten years from now.

Microsoft’s Mark Russinovich and Scott Hanselman published a paper in the Communications of the ACM warning that companies are optimizing for short-term throughput while the pipeline that produces future seniors quietly collapses. Their projection: a reduction in junior hiring between 2024 and 2026 means a proportional senior shortage between 2031 and 2036.

Fast Company ran a piece documenting what happens when you eliminate juniors: burnout. Senior engineers expected to absorb junior workloads plus manage AI tools are burning out faster than companies can replace them. The seniors who were supposed to benefit from not having juniors to mentor are drowning in the work juniors used to share.

This camp will tell you that senior engineers do not appear from nowhere. They are built. Through years of shipping bad code and learning from the failure. Through debugging production incidents at 2am and understanding why the shortcut they took six months ago is the reason they are awake. Through code reviews where a more experienced developer explained not just what was wrong but why it mattered.

Remove that process and you do not get a more efficient industry. You get an industry with a ten-year expiration date on its talent supply. The security consequences of that talent gap are already visible.

The Learning Paradox Nobody Talks About

There is a deeper problem underneath the hiring numbers. AI does not just replace junior work. It replaces junior learning.

Anthropic’s own research found a 17-point comprehension gap between developers who learned with constant AI assistance versus those who coded manually. Debugging skills showed the largest deterioration. The developers who used AI the most understood the least about what they built.

A randomized trial by METR found that experienced open-source developers using AI were 19% slower on average but believed they were faster. If experienced developers misjudge their own productivity with AI, imagine what happens to someone who has never worked without it.

The junior developer who never struggled through a bad database schema never learned why normalization matters. The one who never optimized a slow query by hand never developed intuition for what the database is actually doing. The one who never shipped without tests and got paged at 2am never internalized why testing is not optional.

The pain was the education. Remove the pain and you remove the education. What remains is someone who can prompt an AI to produce code they cannot evaluate, debug, or explain. That is not a junior developer. That is an operator who does not understand the machine they are operating. And when their code passes through a review process that has become a rubber stamp, there is no safety net left.

The Trust Problem

Research shows that junior developers trust AI output at 78%. Seniors trust it at 39%. That gap is not about skepticism versus enthusiasm. It is about pattern recognition.

A senior developer has seen enough production failures to know where AI tends to go wrong. They have intuitions about security, performance, and maintainability that were built through years of consequences. When the AI produces something that looks clean but has a subtle flaw, the senior catches it because they have been burned by that class of flaw before.

A junior developer does not have that library of failures. They cannot evaluate what they cannot contextualize. They accept the output because they have no basis for skepticism. The code compiles. The tests pass. The PR gets approved. The flaw ships to production and nobody knows until a customer finds it.

The paradox: AI is most dangerous in the hands of the people most likely to trust it completely. And the people most likely to trust it completely are the ones with the least experience to validate what it produces.

Neither Camp Has a Plan

The efficiency camp has no answer for the pipeline problem. You cannot run an industry on seniors forever. They retire. They burn out. They leave for management or consulting or farming or whatever people do when they are tired of being on call. If nobody replaces them, you do not have a talent shortage. You have a talent extinction.

The pipeline camp has no answer for the economics. Telling companies to hire people they do not need for work that can be automated is not a workforce strategy. It is charity with a training label. No CFO is going to approve headcount for someone whose primary value is that they might become useful in seven years.

The real answer is probably somewhere in the middle, and neither camp wants to hear it. The junior developer role as it existed from 2010 to 2022 is gone. The work that defined it, the boilerplate and the basic implementations, is automated. That specific job is not coming back.

But the need for people who are learning to become senior engineers has not gone away. The role has to change. What that looks like, whether it is apprenticeships, preceptorships like Microsoft proposed, or something entirely new, nobody has figured out yet. The industry is moving too fast to stop and think about it, which is exactly why it needs to stop and think about it.

The Question Nobody Wants to Ask

If you are a hiring manager: look at your team in five years. Where do your senior engineers come from? If the answer is other companies, ask yourself what happens when every company has the same plan.

If you are a junior developer: the career path that existed for the generation before you is gone. That does not mean there is no path. It means the path has not been built yet and you might have to help build it. The developers who figure out how to combine AI tools with genuine understanding, not just prompting but actual comprehension, will be extraordinarily valuable. The ones who use AI as a crutch instead of a tool will be the ones the efficiency camp points to when they justify not hiring the next cohort.

If you are a senior developer: the juniors you mentor today are not slowing you down. They are the reason someone will be available to maintain your systems after you move on. The thirty minutes you spend on a code review teaching a junior why their approach has a hidden performance problem is not lost productivity. It is the only investment that produces senior engineers.

The junior developer is not dead. But the junior developer role as we knew it is. And the industry has about five years to figure out what replaces it before the consequences become impossible to reverse.

System prompts are not a security boundary for AI agents

AI agents are moving from generating text to taking actions.

They can run commands, send emails, issue refunds, update records, call internal tools, and touch production workflows.

That changes the security model.

A system prompt can guide an agent, but it should not be the thing that enforces policy.

If an action has a real side effect, there should be a control point before that action happens.

The problem

When an agent calls a tool, the important event is not the text the model generated.

The important event is the tool call.

That is where something real can happen.

A model can be manipulated.
A model can hallucinate.
A user can ask for something risky.
A prompt can be ignored or misunderstood.

So the question should not only be:

Did the model intend to do this?

It should also be:

Should this action be allowed under company policy?

A simple example

Imagine a support agent that can issue refunds.

A prompt might say:

Only issue refunds when appropriate.

That is useful guidance, but it is not enforcement.

A better pattern is to check the action before the refund tool executes.

For example:

Refund under $100: allow
Refund between $100 and $500: require approval
Refund over $500: block

Now the rule is not just hidden inside the prompt.

It is enforced before the tool callback runs.

What Enforra does

I’m building Enforra as an open source SDK for AI agent runtime control.

It sits before your tool callbacks and returns a decision before anything executes:

allow
block
require_approval
log_only

The application still owns the actual tool execution.

Enforra does not run your tools remotely.

It gives your app a policy decision before the callback is called.

Why this matters

As agents move into production, teams need more than prompt instructions and logs after the fact.

They need clear policy checks around actions that matter.

That becomes important when agents can touch money, customer data, internal systems, production infrastructure, or business workflows.

The goal is not to make agents less useful.

The goal is to make sure useful agents have a clear control point before they do something risky.

Open source

The initial Enforra SDK is here:

https://github.com/enforra/enforra

NIS2 Article 21 in Azure: Implementing Network Security Controls with Terraform

NIS2 Article 21 in Azure: Implementing Network Security Controls with Terraform

Tags: terraform, azure, security, devops

Canonical URL: https://woitzik.dev/blog/nis2-article-21-azure-terraform

Most NIS2 content is written by lawyers for lawyers. This article maps Article 21 network security requirements to concrete Azure resources — with Terraform code, not legal theory.

Disclaimer: This covers technical network infrastructure controls relevant to NIS2 Article 21. Full compliance requires organizational measures beyond infrastructure code. Nothing here constitutes legal advice.

What NIS2 Article 21 Actually Requires (For Engineers)

Four concrete network security areas:

  1. Network Segmentation — isolate systems by criticality
  2. Access Control — Default-Deny at network and identity layer
  3. Minimize Attack Surface — eliminate unnecessary public endpoints
  4. Auditability — traceable, version-controlled infrastructure changes

Here’s how each maps to Azure Terraform resources.

Control 1: Network Segmentation via Hub & Spoke

Spoke-to-Spoke traffic blocked by default — all cross-workload communication routes through the Hub:

resource "azurerm_virtual_network_peering" "hub_to_spoke1" {
  name                      = "peer-hub-to-spoke1"
  virtual_network_name      = azurerm_virtual_network.hub.name
  remote_virtual_network_id = azurerm_virtual_network.spoke1.id
  allow_forwarded_traffic   = true
}

Spokes only peer with the Hub — never with each other. Cross-Spoke traffic must be explicitly routed through the Firewall.

Control 2: Default-Deny NSG Baseline

security_rule {
  name                       = "Allow-VNet-Inbound"
  priority                   = 100
  access                     = "Allow"
  source_address_prefix      = "VirtualNetwork"
  destination_address_prefix = "VirtualNetwork"
}

security_rule {
  name                       = "Deny-Internet-Inbound"
  priority                   = 4096
  access                     = "Deny"
  source_address_prefix      = "Internet"
  destination_address_prefix = "*"
}

Priority gap (100 → 4096) leaves room for application rules without restructuring the baseline.

Control 3: Egress Control via Forced Tunneling

All outbound Spoke traffic through Azure Firewall — logged, inspectable, FQDN-controlled:

route {
  name                   = "route-to-firewall"
  address_prefix         = "0.0.0.0/0"
  next_hop_type          = "VirtualAppliance"
  next_hop_in_ip_address = azurerm_firewall.fw.ip_configuration[0].private_ip_address
}

# Critical — without these, Windows VMs lose activation
route {
  name           = "bypass-azure-kms"
  address_prefix = "23.102.135.246/32"
  next_hop_type  = "Internet"
}

The Firewall log is your audit trail for NIS2 Article 21(2)(b) — monitoring and incident detection.

Control 4: Zero Public PaaS Endpoints

resource "azurerm_key_vault" "kv" {
  public_network_access_enabled = false

  network_acls {
    default_action = "Deny"
    bypass         = "AzureServices"
  }
}

An external scanner gets no TCP connection — the endpoint doesn’t resolve to a public IP. Directly satisfies NIS2 Article 21(2)(h).

Control 5: No Static Credentials

Managed Identity + scoped RBAC — no connection strings, no access keys:

resource "azurerm_role_assignment" "app_kv_secrets" {
  scope                = azurerm_key_vault.kv.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = azurerm_linux_function_app.app.identity[0].principal_id
}

When the resource is deleted, identity and permissions are automatically destroyed.

Control 6: Auditability via IaC

Every change is a Git commit. terraform plan is an audit artifact. No ClickOps, no undocumented Portal changes.

What This Covers — and What It Doesn’t

This addresses technical network controls only. Full NIS2 compliance also requires incident response procedures, supply chain risk management, business continuity measures, and organizational governance.

Infrastructure code is the foundation. Compliance is the full building.

Full article with complete code for all six controls on my blog.

👉 Full article on woitzik.dev