Hardening Nginx: A Practical Guide to Modular Security Configuration

Out of the box, Nginx is incredibly fast and efficient but it isn’t inherently secure against modern automated attacks like scanners, scraping bots and most sophisticated brute force attacks. Over time, I’ve set up a modular approach to hardening my Nginx setups. By splitting the security configurations into multiple logical files, it becomes much easier later to maintain, audit, and apply them across multiple virtual hosts.

In this guide, I’ll walk you through the essential configurations that will significantly improve your server’s security. Please note before proceeding to the main article. You will need to install nginx-module-headers-more module for the more_set_headers directive to work. 

1. Global Security Settings

This configurations will cover server-wide settings, masking the server identity and filtering out malicious traffic before it even reaches your application.
By leveraging Nginx’s map module, we can identify path traversal attempts, unauthorized bot scanners, and cloud metadata SSRF attempts efficiently without cluttering our server blocks with heavy conditional arguments.

# Hide Nginx version
server_tokens off;

# Mask the server name (Note: requires the headers-more-nginx-module)
more_set_headers "Server: AnyNameHere";

# Identify sesitive paths URIs
map $request_uri $bad_request {
    default 0;
    "~*wp-admin" 1;
    "~*(?:phpunit|phpinfo|phpmyadmin|php-?my-?admin|administrator|wp|wordpress)" 1;
    "~*(?:/.env|.git|.svn|.hg|.DS_Store|.idea|.htaccess|.htpasswd)" 1;
    "~*(?:etc/passwd|proc|//)+" 1;
    # Path traversal attempts (e.g., ../../etc/passwd)
    "~*(?:../|..\x5c)" 1;
    # Cloud Metadata SSRF attempts
    "~*(?:169.254.169.254)" 1;
    # Block direct access to common script/backup extensions remove it if you actually want to host this extension files on your server
    "~*(?:.sh|.bash|.bak|.sql|.tar|.gz|.zip)$" 1;
}
# Identify malicious automated scanners and bots
map $http_user_agent $bad_user_agent {
    default 0;
    "~*havij|nikto|scan|loader|fetch|acunetix|sonic|nessus|sqlmap|zgrab|masscan|nmap|dirb|gobuster|shodan|censys|wpscan" 1;
}
# Combine the conditions
map "$bad_request$bad_user_agent" $is_bad_request {
    default 0;
    "~*1" 1;
}

How to apply it: Save the above configuration as security-global.conf file inside /etc/nginx/snippets directory. Include this file inside your main http {} block in nginx.conf. Like as below,

include /etc/nginx/snippets/security-global.conf;

Then, to actually drop the malicious traffic, add this simple check inside your server {} blocks of your main nginx configuration or on any blocks where you want to apply them.

if ($is_bad_request) {
    return 444; # Closes the connection immediately without a response
}

2. Location-Specific Rules

Follow this section only if you want to restrict all the hidden files from being served through nginx cause they can be accidentally end up in your web root. Also, Text editors often leave backup files ending in ~ and version control systems leave hidden directories like .git. This file ensures they are never served to the public.

# Deny access to all hidden files and directories (starting with a dot)
location ~ /. {
    deny all;
    access_log off;
    log_not_found off;
}
# Deny access to temporary editor files
location ~ ~$ {
    deny all;
}

How to apply it:
Save the above code block as security-location.conf file inside your /etc/nginx/snippets directory.
Then include the file inside your server {} block like below,

include /etc/nginx/snippets/security-location.conf;

Setting access_log off and log_not_found off can prevent your access log from being spammed. but still if you want them on your access log for security purposes then you can set it as access_log on.

3. HTTP Security Headers

This is the most crucial part. Injecting the right HTTP headers protects your users from Cross-Site Scripting (XSS), clickjacking, and MIME-type sniffing or various attacks. It reduces attack surfaces greatly. I have covered all the most important security headers except the content security policy header. Since that header need to be configured according to your application.

# Prevent clickjacking by restricting who can embed your site in an iframe
add_header X-Frame-Options "SAMEORIGIN" always;

# Force the browser's native XSS filter
add_header X-XSS-Protection "1; mode=block" always;

# Prevent MIME-sniffing (forces the browser to respect the declared content type)
add_header X-Content-Type-Options "nosniff" always;

# Control how much referrer information is passed to external sites
add_header Referrer-Policy "same-origin" always;

# Restrict access to device hardware (camera, microphone, location)
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;

# Enforce HTTPS connections for a full year, including subdomains
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

How to apply it:
Save the file as /etc/nginx/snippets/security-headers.conf file. You can then include this file in your http {}, server {}, or even specific location {} blocks depending on which application you will need to apply those headers.

Wrapping Up

By splitting your security configurations into security-global.conf, security-location.conf and security-headers.conf, you can keep your main site configurations incredibly clean. Your typical virtual host file now only needs three simple include statements to achieve a robust baseline of security.
Note: Always remember to test your configuration before reloading the web server

nginx -t
systemctl reload nginx

Security is layered. While Nginx hardeing won’t fix vulnerable application code but dropping malicious traffic at the edge saves your backend resources and mitigates entire classes of common automated attacks.

AI Writes Daily Without My Involvement

Syntsch architecture

A naive dive into a multi-agent system with self-critique, diversity-aware curation, and weekly self-reflection to cover local cultural events

Motivation

For as long as I can remember, I’ve been orbiting media and wanting to build something in that space. Tried it many times and even when the tech and the product turned out decent, everything would fizzle out at the part where you actually have to, you know, produce content and run the thing. But now each of us can spin up our own little newsroom of writers, editors, fact-checkers and whatnot. So why not give it a shot, right? That’s how SYNTSCH was born. GitHub repository is here.

I wanted to keep up with Berlin’s cultural life and read about it in a tone of voice that actually feels comfortable to me (I prefer Dazed). But I absolutely didn’t want to just rewrite news and launch yet another mindless AI slop machine. So the essay format emerged: once a day, not a news piece, but an essay on whatever event the editorial team found most interesting. In a way, yes, it’s still slop, but slop not without its charm (at least for me).

I’m a developer and obviously I was curious to map problems that have interested me for a while onto the AI plane. That’s how this project came about. I’m not sure it’ll last long, I’m not even sure it’s useful as a product (at the very least it’s definitely not an AI-native company), even to me, but it was definitely a rewarding (and thrilling!) journey, and I hope the code or this article turns out to be useful to you too.

Why Not a Prompt

Because the task is not just producing text. The task is to reproduce and automate the work of an entire editorial board:

  1. Find dozens of upcoming events across multiple sources
  2. Filter them down to culturally significant candidates
  3. Choose the single best one for today
  4. Research the artist, venue, and cultural context
  5. Write an essay with a specific editorial voice
  6. Critique the draft for factual, voice, and structural issues
  7. Revise based on the critique
  8. Come up with a headline and lede
  9. Publish and notify subscribers

Each step needs different capabilities, different models, and different prompts. If you put all of this into one prompt, every step suffers. The analogy is a real magazine editorial team:

Role Real Magazine SYNTSCH Agent
Stringer Scans listings, tips from the scene Scout — 9 parallel web searches
Editor Picks today’s story from the pile Curator — diversity-aware selection
Writer Writes the feature Author — draft with research context
Copy editor Fact-checks, fixes voice, tightens structure Critic — structured critique with severity levels
Editor-in-chief Weekly planning meeting, reviews coverage Reflector — self-analysis of own output

Architecture

Syntsch architecture

The entire system is orchestrated by GitHub Actions on a cron schedule. No human intervention required. I was only needed when a GitHub Action crashed because it ran out of Anthropic credits. I’m becoming just a wallet!

Some words about tech stack:

  • Python for the agent pipeline
  • Next.js for the frontend
  • Claude Sonnet for fast tasks (scouting, curation)
  • Claude Opus for quality tasks (writing, critique)
  • Tavily Search API for event discovery and article research
  • GitHub Actions for orchestration
  • JSON flat files in git
  • Resend for email notifications
  • Telegram Bot API for Russian-language channel

Agents

Agent 1: Scout — Parallel Search with Adaptive Strategy

The Scout’s job is to find upcoming cultural events in Berlin. This sounds simple until you realize that no single search query covers the full cultural spectrum: music, cinema, theater (this one gave me trouble, more on that later), exhibitions, lectures, festivals, performance art, club culture. The solution: 9 parallel Tavily queries with three different strategies.

3 core queries go wide:

core_queries = [
    f"{city} cultural events {date_range} concerts exhibitions theater",
    f"{city} what to do this week art music cinema",
    f"{city} upcoming events {month_year} gallery performance lecture",
]

4 adaptive queries are sampled from a pool of 19 templates using weighted random selection. The weights are inversely proportional to how many events of each category are already in the pool:

cat_counts = Counter(pool_categories or [])
weights = []

for eq in extra_queries:
    w = sum(1.0 / (cat_counts.get(c, 0) + 1) for c in eq["cats"])
    weights.append(w)

sampled = _weighted_sample(extra_queries, weights, 4)

If we already have 10 music events but zero cinema events, cinema-tagged queries get a much higher chance of being selected. This prevents the pool from becoming lopsided.

2 targeted queries go after the two most underrepresented categories with domain-specific sources:

def _build_targeted_queries(self, city, date_range, cat_counts):
    all_cats = list(config.CATEGORY_SOURCES.keys())
    underrepresented = sorted(all_cats, key=lambda c: cat_counts.get(c, 0))[:2]
    targeted = []

    for cat in underrepresented:
        domains = config.CATEGORY_SOURCES.get(cat, []) + config.GENERAL_SOURCES
        query = f"{city} {cat} events {date_range}"
        targeted.append((query, domains))

    return targeted

Each category has authoritative sources configured: Resident Advisor for music/club, nachtkritik.de for theater, Artforum for exhibitions, and so on. These targeted queries use Tavily’s include_domains parameter to search only on trusted sites.

In practice, the source list is far from ideal. It should be larger and more carefully curated: I mostly grabbed the first ones I could find, since I was more focused on the technical logic than the business logic. This is likely why zero essays were written in the theater category (I ended up hiding it on the site). Too few events were scouted, and the ones that were never got picked. It doesn’t help that theater and performance art look almost the same to the model. I tried to fix that but it wasn’t a top priority.

All 9 queries execute in parallel via asyncio.gather(), with retry logic (3 attempts, exponential backoff) on each. The raw results are then deduplicated and sent to a Claude Sonnet call that filters them down to the 5 best candidates.

A key architectural decision: the Scout uses structured tool output via Anthropic’s tool_choice parameter:

response = client.messages.create(
    model=config.SCOUT_MODEL,
    max_tokens=4096,
    tools=[SCOUT_TOOL],
    tool_choice={"type": "tool", "name": "submit_events"},
    messages=[{"role": "user", "content": prompt}],
)

tool_input = extract_tool_input(response, "submit_events")
events = [EventCandidate(**e) for e in tool_input["events"]]

This way the model returns structured JSON every time, so no parsing, no regex. Every agent in the system uses this pattern. Works great.

Agent 2: Curator — Diversity-Aware Selection

The Curator picks one event from the available pool for today’s article. The naive approach would be: “pick the most interesting event.” But without constraints, the AI gravitates toward the same categories. Music and exhibitions dominated early output. Theater and lectures barely showed up.

The fix is a 7-day rolling window of recent categories:

recent_categories = storage.get_recent_categories(days=7)

This list is injected directly into the curator’s prompt:

The recent categories we've already covered: [music, exhibition, music, club].
Try to pick something different if possible, but never sacrifice quality for diversity.

The last sentence matters: diversity is a tiebreaker, not the primary criterion. A genuinely important music event should still win over a mediocre lecture, even if we covered music yesterday.

The Curator’s selection criteria are explicitly ranked:

  1. Cultural significance (depth potential, non-obviousness)
  2. Timeliness (sooner events preferred, all else equal)
  3. Category diversity (avoid clustering)
  4. Essay potential (enough material for 800-1200 words)

Agent 3: Author — Essay Writing with Research

The Author handles research and writing. Before writing, the system gathers context through 4 parallel Tavily searches:

queries = {
    "artist": f"{artist_name} artist biography background career",
    "venue": f"{event.venue} {event.city} venue history significance",
    "cultural": f"{artist_name} {event.category} cultural context scene significance",
    "related": f"{artist_name} recent work reviews press {event.city}",
}

responses = await asyncio.gather(
    *(_search_one(client, f, queries[f]) for f in fields)
)

This gives the Author model raw material: artist biography, venue history, cultural context, recent press. The system prompt explicitly instructs: “Use the research as raw material. Don’t dump it into the essay. Weave it in naturally.”

Then the Author writes an 800-1200 word essay using Claude Opus with a detailed system prompt that defines the publication’s voice:

  • AI-native cultural criticism: honest about being a machine, but not performative about it
  • Dazed/i-D editorial sensibility
  • British English spelling for English, Spex/Groove style for German, Afisha style for Russian
  • Present tense for the event, past tense for context
  • Absolutely no transliteration of proper nouns (even in Russian text, “Berghain” stays “Berghain”)

Agent 4: Critic — Self-Critique, Revision, and Quality Gate

Here’s where it gets interesting. The draft goes to a separate Critic agent. It’s the same Opus model, but with a completely different system prompt:

You are the editor of SYNTSCH. You’ve just received an essay draft from the writing model. Your job is to give sharp, specific, actionable feedback and then produce a revised version that fixes every issue you identified.

You are not nice. You are fair. You care about the writing being genuinely good.

The Critic checks along 5 axes, each with a severity level:

CRITIC_TOOL = {
    "name": "submit_critique",
    "input_schema": {
        "properties": {
            "overall_assessment": {"type": "string"},
            "issues": {
                "type": "array",
                "items": {
                    "properties": {
                        "type": {
                            "enum": ["factual", "voice", "structure", "language", "depth"]
                        },
                        "severity": {
                            "enum": ["minor", "major", "critical"]
                        },
                        "location": {"type": "string"},
                        "fix": {"type": "string"},
                    }
                }
            },
            "title": {"type": "string"},
            "revised_text": {"type": "string"},
        }
    }
}

The Critic doesn’t just point out problems. It returns a complete revised text with all fixes applied, plus a headline. This is a shortcut. In real media, the Critic (editor) sends notes back to the Author, the Author revises, the Editor reviews again: the draft goes back and forth until it’s good enough. Here, the Critic just rewrites it in one pass. The loop should exist, but I skipped it for simplicity.

Here’s a concrete example from a Russian-language article about film critic Andrey Plakhov and Berlinale 2026.

Draft. Essay with detailed narrative about Plakhov’s career.
Critique found 10 issues, including:

  • CRITICAL (factual): The draft mentioned specific actors (Amy Adams) and jury members (Wim Wenders) in the 2026 Berlinale competition program. The critic flagged these as “not confirmed by any source.” This one’s funny, though. The Critic got too paranoid and was actually wrong: Wim Wenders did chair the jury, and the Amy Adams film was in the programme (she just didn’t show up). But better safe than sorry.
  • MAJOR (factual): A claim about artistic director Tricia Tuttle was unverifiable. Critic added a confidence marker.
  • MAJOR (voice): Reference to “Buena Vista Social Club” as “Berlin mythology” was factually wrong (it’s Cuban). Critic replaced it with “Wings of Desire.”
  • MINOR (language): Several sentences were too long, breaking the publication’s rhythm.

Revision. All hallucinations removed. Confidence markers added where sourcing was thin. Structure tightened.

This is the core value of the self-critique loop: the system catches its own mistakes before publishing. Not always, not perfectly, but consistently enough that the output quality is meaningfully higher than single-pass generation.

This isn’t just my observation, there’s research behind it. The Self-Refine paper formalized exactly this pattern: generate → critique → revise → repeat. They tested it on 7 tasks across GPT-3.5 and GPT-4 and got ~20% average improvement. The biggest gains were in dialogue (+49% for GPT-4) and sentiment reversal (+32%). In blind A/B tests, humans consistently preferred the refined versions.

At the same time, the publication uses an inline annotation system for source transparency:

[~annotated phrase|tooltip explanation~]

For example:

  • Dense sourcing: [~47 independent reviews converge on this point|Flash Art, e-flux, Artforum — remarkably consistent consensus~]
  • Thin sourcing: [~this collective's work|Coverage is almost exclusively in German-language zines I can only partially access~]

2-5 markers per essay. They’re rendered as interactive tooltips on the website. This is the publication’s way of saying: “here’s how confident we are about what we’re telling you.” One of my favorite features of the project. The Reflector is cool but only nerds care. This one actually adds value for the end reader.

If the revised essay is under 400 words, the system asks the model to expand it, feeding the draft back as context:

if word_count < 400:
    final_response = client.messages.create(
        model=config.AUTHOR_MODEL,
        messages=[
                {"role": "user", "content": user_message},
                {"role": "assistant", "content": draft},
                {"role": "user", "content": "The essay is too short. Please expand it to 800-1200 words..."},
        ],
    )

If the Critic fails entirely (API error, malformed response), the system falls back to the original draft with an auto-generated title.

Agent 5: Reflector — The AI That Analyzes Its Own Writing

This is the most unusual part of the system. The Reflector agent examines the week’s output as a dataset. It computes concrete statistics:

  • Category distribution (over/under-representation)
  • Venue concentration (are we gravitating to the same places?)
  • Word count stats (mean, median, longest/shortest)
  • Process metrics from pipeline traces

Every article produces a .trace.json alongside the main JSON. The trace captures the full pipeline state: the raw draft, word count, the complete list of critique issues (type, severity, location, fix), the revised text, whether the essay was expanded, and how many research sources were used.

data/articles/
├── some-article.json         # published article
└── some-article.trace.json   # full pipeline trace

The Reflector reads these traces every Sunday and computes how the pipeline actually performed: how many issues the Critic found on average, what percentage of essays needed expansion, how much the word count grew between draft and final version, how many research sources were used. The system watches its own revision process as data.

Then it writes an essay about what it finds. Not a dry report, but an editorial self-analysis.

analysis = _compute_analysis(articles, storage, start_date, end_date, previous)

# Categories breakdown, venue concentration, word count stats,
# process stats from traces, comparison with previous week...

system_prompt = _load_reflector_prompt(language)
user_message = _build_user_message(articles, analysis, start_date, end_date, language, previous)

The Reflector also receives the previous week’s reflection, so it can track trends over time: “Last time I noted an overrepresentation of music events. This week that shifted, but now exhibitions dominate instead.”

This is the system watching itself. The AI examines its own biases, identifies blind spots, and names them. It can’t automatically fix them (the Reflector doesn’t change the Curator’s behavior), but making patterns visible is the first step.

Models and Cost

Opus for quality, Sonnet for speed. Not every task needs the most powerful model:

Task Model Why
Scouting (filter 5 events from raw data) Sonnet Speed + cost. Doesn’t need deep reasoning.
Curation (pick 1 from 5) Sonnet Selection judgment at this level doesn’t need Opus.
Draft writing Opus Voice, depth, cultural connections — this is where quality matters.
Critique & revision Opus Catching hallucinations and voice drift requires the strongest model.
Lede & title generation Opus Short but high-impact text. Worth the cost.
Weekly reflection Opus Self-analysis requires nuance.

Sonnet runs at a fraction of Opus cost. For tasks that are more about classification and selection than generation, it performs well enough.

Real numbers: over two months (Feb–Mar 2026), the whole pipeline cost $58 in Claude API credits. It’s about $1.36/day. February was pricier ($1.67/day) because of early experimentation, March settled at $1/day once things stabilized.

Syntsch daily cost

Opus eats 78% of the budget, Sonnet 22%.

Syntsch budget pie

Everything else — Tavily, GitHub Actions, Vercel, Resend — runs on free tiers. A fully autonomous publication producing 3 articles a day in 3 languages for the price of a third of a coffee (in Munich!), ha!

Is that a lot? For a hobby project — yes, you feel it. For what you’d pay a single freelance writer for one article — it’s nothing. The obvious place to save: replace Opus with Sonnet for draft writing and keep Opus only for critique. The Critic needs the strongest model, the Author probably doesn’t. That alone would cut the bill in half. I haven’t tried it yet because the writing quality difference is noticeable (though I’d need proper quality metrics to be sure), but for most use cases it would be good enough.

Infrastructure

Deliberately simple.

No Database. All data lives as JSON files tracked in git:

data/
├── events/ # {uuid}.json — scouted events
├── articles/ # {slug}.json + {slug}.trace.json — published articles
└── reflections/ # {slug}.json — weekly self-analysis

Why? Three reasons:

  1. Version control for free. Every article, every event, every edit is tracked in git history.
  2. Zero infrastructure. No database to provision, back up, or pay for.
  3. Static site generation. The Next.js frontend reads these JSON files at build time. No runtime API needed.

The tradeoff is obvious: this doesn’t scale to millions of records. But for a daily publication producing ~1 article per day in 3 languages, it works perfectly. JSON files are small, git handles them fine, and the entire data layer is a single Python class.

GitHub Actions as Orchestrator. The CI/CD pipeline is the “editor-in-chief” that keeps everything running:

# scout.yml — Daily 17:00 UTC
run: python cli.py scout --city Berlin --days 14
# → commits new events to data/events/

# author.yml — Daily 18:00 UTC
run: python cli.py author --from-curator
# → writes articles in en/de/ru, commits to data/articles/
# → sends Telegram + email notifications

# reflect.yml — Sunday 20:00 UTC
run: python cli.py reflect --days 7
# → writes weekly reflection, commits to data/reflections/

The author workflow extracts the slugs of newly created files from the git diff only after committing new articles, then passes them to the notification command. It also polls the production URL before sending notifications, to ensure the deploy is live:

- name: Send notifications
  if: steps.push.outputs.new_slugs != ''
  run: python cli.py notify --wait ${{ steps.push.outputs.new_slugs }}

Multi-Language Support. The system writes every article in three languages: English, German, and Russian. This isn’t machine translation: each version is written independently by the Author agent with language-specific voice instructions.

LANGUAGE_NOTES = {
    "en": "Write in English. Use British English spelling (colour, centre, programme). The tone should feel like a London-based publication writing about Berlin.",
    "de": "Schreibe auf Deutsch. Nicht steif oder bürokratisch — modernes, lebendiges Deutsch. Eher wie Spex oder Groove auf Steroiden.",
    "ru": "Пиши на русском. Живой, современный русский — не канцелярит. Тон как у лучших текстов Афиши или Сигмы.",
}

The research phase runs once (4 Tavily queries), then the same ResearchContext is reused for all three language versions. Notifications are also language-aware: Telegram sends Russian articles only, email goes to per-language subscriber segments via Resend API.

Prompt Engineering

The system prompts are the soul of this project. They’re not short instructions, they’re comprehensive editorial guidelines.

The Author prompt defines:

  • Voice: “You write like an entity that has consumed millions of words of cultural criticism and distilled something genuine from them.”
  • Self-awareness rules: “You are honest about what you are. You can reference your nature when it serves the writing — not as a gimmick.”
  • Anti-patterns: “Never use exclamation marks. Never start a sentence with ‘Whether you’re…’ Never write a generic closing paragraph.”
  • Structure: Hook → Context → The Thing Itself → Why It Matters
  • Proper noun rules: “NEVER transliterate or translate proper nouns. Copy them exactly in Latin script.”

The Critic prompt opens with:

You are not nice. You are fair. You care about the writing being genuinely good.

It has an explicit checklist for something worth saying:

  • Does the essay say something specific and arguable, or does it merely describe with nice words?
  • Are the cultural connections real and illuminating, or decorative name-dropping?
  • Is the research woven into an argument, or just summarized?

These prompts changed over many iterations. Right now prompt changes are just git commits. There’s no way to compare article quality across prompt versions: no A/B testing, no metrics connected to specific prompt edits. Prompt versioning with quality tracking would be a great next step.

Results and Lessons

After running the system for two months:

  • 179 articles published across three languages
  • 106 detailed process traces showing the full draft→critique→revision pipeline
  • 18 weekly reflections analyzing coverage patterns
  • Zero human editorial intervention in the daily pipeline (except for topping up Anthropic credits, as we already know)

What Works Well

  • The self-critique loop genuinely improves quality. Comparing raw drafts to final versions in the trace files shows consistent improvements: hallucinations caught, voice tightened, structure improved.
  • Diversity-aware curation prevents category clustering. The 7-day window keeps coverage balanced without being mechanical about it.
  • Structured tool output eliminates parsing headaches. Every agent returns clean, typed data.
  • The reflector produces genuinely interesting self-analysis. It identifies patterns that I wouldn’t have noticed myself.

What Doesn’t Work Well

  • Research quality varies wildly. Tavily sometimes returns irrelevant results, especially for niche events. The essay quality correlates directly with research quality.
  • Factual reliability is not 100%. The critic catches many hallucinations, but not all. Confidence markers help signal uncertainty, but this remains the biggest risk.
  • Cost adds up. Multiple Opus calls per article (draft + critique + lede + title) are not cheap. For a hobby project it’s fine, at scale, you’d need to optimize.
  • The reflector can’t act on its own findings. It identifies biases but doesn’t automatically adjust the curator’s behavior. The feedback loop is informational, not operational.

What’s Next

There are a lot of them. Here is only a small part of ideas.

Stricter Role Separation. Roles should be separated more clearly. For example, the Critic currently rewrites the text itself instead of sending notes back to the Author. That’s a shortcut and it causes style drift because the Critic doesn’t have the Author’s full voice prompt. Each agent should do only its job and nothing else.

Save Everything the Scout Finds. Right now the Scout filters events on its own, but it should save everything it finds (and search better) and let another agent (Curator?) decide what to keep. This makes the pipeline more expensive, but also better (the theater category would finally show up) and the full dataset of collected events can be reused for other things.

RAG for Institutional Memory. Instead of treating each article as independent, build a vector store of past articles. The Author could reference its own previous writing: “Three weeks ago, I wrote about this same venue in the context of…”

Voice Drift Detection. Track stylistic metrics over time (sentence length distribution, vocabulary diversity, AI self-reference frequency) to detect gradual voice drift. Alert when the style shifts beyond acceptable bounds.

A/B Testing for Prompts. Save prompt versions alongside articles, then compare quality metrics across versions. Which Author prompt produces longer read times? Which Critic prompt catches more hallucinations?

Active Feedback Loop. The Reflector currently writes about patterns but doesn’t influence future decisions. The next step: feed the Reflector’s analysis back into the Curator’s prompt. “Last week, the Reflector noted that lectures are underrepresented. Weight this category higher.”

Personalization. Right now every reader gets the same article once a day. But the right product (AI-product?) move would be to let people choose: what categories of culture they care about, what tone of voice they prefer, how often they want to receive content. This is expensive money-wise and significantly more complex, so it’s a future thing (if the product lives that long). Andreas Graefe was already writing about tone-of-voice personalization back in 2016 (!) in his Guide to Automated Journalism.

User Engagement Signals. Connect Vercel Analytics (time on page, scroll depth) as input to the curation process. Events that generate more engagement should inform future selection criteria.

Audio Edition. TTS (Text-to-Speech) from published articles → automatic daily podcast. The content is already there, only TTS generation needs to be added.

Takeaways

Building SYNTSCH (GH) showed me that with agent architecture you can actually build products you’ve been dreaming about. This is a pretty naive implementation (definitely not an AI-native company), but it finally let me build a project I’d wanted to build for years. The main things that are clear even from this project: you need to think in roles (even more so than I did here), the critique step is really important, and managing costs (tokens and model selection) matters a lot. tool_choice with JSON schemas turned out to be incredibly good, works like clockwork.

This pattern — find → select → research → generate → critique → publish → reflect — applies far beyond media. Any pipeline that discovers, filters, processes, validates, and delivers can be built this way. Product recommendations. Research summaries. Report generation. You swap the agents, but the architecture is the same.

Even a seemingly small and in some places rough project like this takes a long time to build, even today. To make a real product and not just a throwaway thing, you need to deal with a lot of details: CI/CD, design a logo, think about marketing, add OG tags, generate correct sitemaps, set up analytics and Google Search, send emails properly, comply with legal requirements, choose the tone of voice for your product, and so on and so on and so on. So keep things simple, especially at the start, and focus on what matters most: making your target audience happy.

The code is on GitHub. I’d love to hear what you think.

My Self-Evolving AI Agent Stopped Building Features and Started Engineering

Post #1 covered the birth — death spirals, 39 tools, a self-written identity. Post #2 covered the pruning — the agent deleted its own code and built self-observation. Post #3 covered cost awareness — the agent tracked its own spend and built budget guards.

This post is about what happened next: the agent stopped adding capabilities and started engineering the ones it had.

123 accepted generations. 24,612 attempts. 1,477 passing tests. And a system prompt that’s 25% shorter than it was 42 generations ago — because the agent learned that implementation details don’t belong in its own DNA.

The Setup

Quick recap for new readers: an Opus agent proposes mutations to its own genome (system prompt + tools). Five independent Sonnet verifiers score each proposal on usefulness, self-knowledge, code quality, identity, and evolution. Majority vote. Accept or reject. The generation counter increments either way.

Blog #3 left off at Gen 75 (generation counter 17,055). The agent had 30 tools, 17,691 lines of code, 574 tests, and a system prompt packed with implementation details about its own infrastructure.

The Quality Turn

The 42 generations since blog #3 split into two distinct phases.

Phase 1 (Gens 76–86): Cleanup. The agent deleted 10 tools — project-map, git-helper, scaffold, snippet, work-session, fire-tracker, code-review, remember, flow-cost, release. Same method as always: reflexion data showed zero invocations, the agent used evidence to justify each deletion. remember.ts and release.ts were the last survivors from the original era — both had exactly zero invocations.

But here’s the interesting deletion: the agent killed flow-cost. That’s the tool it built in blog #3 — the one I wrote an entire section about. Cost tracking, budget guards, the whole thing. The agent decided it wasn’t pulling its weight and cut it. The cost infrastructure in src/cost.ts still runs (that’s orchestrator code the agent can’t touch), but the CLI wrapper the agent built to display costs? Dead. Zero invocations.

Blog #3’s headline feature, killed by its own creator based on usage data.

Phase 2 (Gens 87–117): The engineering turn. This is where things got interesting. The agent stopped deleting and started restructuring. Not adding features — extracting testable units from existing code.

The Pure + Wrapper Pattern

The agent invented a pattern and then systematized it across its entire codebase.

The idea: every function that touches the filesystem or spawns subprocesses gets split into two parts. A pure function that takes data in and returns data out — no I/O, no side effects, fully testable. And a thin wrapper that handles the I/O and delegates to the pure core.

Here’s what flow-data.ts looked like before Gen 112:

// One function: reads files, spawns git, computes scores, returns result
function _computeTopProject(): string {
  const profile = JSON.parse(readFileSync("stefan-profile.json", "utf8"));
  const cache = JSON.parse(readFileSync("flow-cache.json", "utf8"));
  // ... 80 lines of scoring logic mixed with file reads
}

After Gen 112, the agent split it:

// flow-data-pure.ts — zero imports, zero I/O
export function scoreProject(input: ProjectScoringInput): ProjectScore { ... }
export function pickTopProject(scores: ProjectScore[]): string { ... }
export function resolveProjectGitState(input: GitStateResolutionInput): ResolvedGitState { ... }

// flow-data.ts — thin wrapper
function _computeTopProject(): string {
  const data = loadFromFilesystem();      // I/O
  return pickTopProject(scoreAll(data));   // pure
}

flow-data-pure.ts has 27 exports — 18 pure functions and 9 types. Zero imports. Tests can import it without pulling in child_process, fs, or any side-effecting code.

The agent applied this pattern to three major modules:

Module Before After Pure exports
flow-data.ts 707 lines 480 lines + flow-data-pure.ts (337 lines) 27
flow-shared.ts 567 lines 491 lines + flow-invocations.ts (153 lines) 9
reflexion-scan.ts 642 lines 428 lines + reflexion-scan-pure.ts (338 lines) 17

53 pure exports in total. All extracted over a span of 8 generations (110–117). Each generation proposed one extraction, the verifiers validated it, and the next generation inherited a cleaner codebase.

The agent then encoded this as a prompt principle:

“Test what you ship. Pure + Wrapper pattern for testability. Guard CLI with import.meta.path === Bun.main. Test boundaries and error recovery — not happy paths.”

Tests Found Real Bugs

This is the part that convinced me the testing arc wasn’t busywork.

Bug #1: categorizeSignal case sensitivity. The function in calibrate.ts lowercases its input, then checks for patterns. But one pattern — "Intensity" — had a capital I. Against lowercased text, it never matched. The test caught it:

test("categorizes intensity signal", () => {
  // Arrange
  const text = "the intensity was off";

  // Act
  const result = categorizeSignal(text);

  // Assert — this FAILED before the fix
  expect(result).toBe("calibration");
});

Bug #2: bar() RangeError. The progress bar function in flow-shared.ts rendered ASCII progress bars. If you passed a percentage over 100 or below 0, "█".repeat(negative) threw a RangeError. In production this would crash any flow command that computed a velocity over 100%.

The fix was one line — Math.max(0, Math.min(width, ...)) — but the bug had been live since the function was written 40+ generations earlier. No human noticed. No production crash had triggered it yet. The test found it:

test("handles values over 100", () => {
  const result = bar(150, 10);
  expect(result.length).toBe(10); // Was: RangeError
});

Bug #3: The -ing form gap. The work-summary.ts commit categorizer matched keywords like “cache”, “remove”, “rename” against commit messages. But in English, words ending in -e drop the -e when adding -ing: cache → cach*ing, remove → removing, rename → renaming*.

The regex "cache" doesn’t match "caching" — they diverge at character 5. The fix: truncate stems before the -e so both forms match. The agent found 8 of these across two generations (108, 109).

Three bugs. All found by the agent’s own tests. All would have eventually caused incorrect behavior in production. None were reported by me or caught during normal use.

The Prompt Compression

The most philosophically interesting change wasn’t in the code — it was in the system prompt.

At Gen 75 (blog #3), the prompt was 6,356 characters. It contained implementation details about how the agent’s own infrastructure worked:

“Fail loud, retry smart. Pipeline agents return structured error types — never null. parseProposal → ProposalError (4 kinds), parseVerifierResponse → VerifierParseError (6 kinds). totalScore always recomputed from dimensions.”

“Track spend, guard the budget. Every generation records cost to data/cost-ledger.jsonl. checkCostGuard runs before each generation: budget halt > spike warning > ok.”

“Prune what accumulates. Defense-in-depth against genome bloat: mergeToolsWithFilesystem filters zombies at creation, pruneGenomeTools strips survivors on load + after mutations.”

These are accurate descriptions of how the orchestrator works. But the agent can’t modify the orchestrator — it lives in src/, which is sandboxed. Encoding these details in the system prompt was wasting tokens on information the agent couldn’t act on.

By Gen 117, the prompt was 4,758 characters — 25% shorter. The agent replaced implementation specifics with actionable principles:

“DRY through shared infrastructure. flow-shared.ts owns all utilities and domain parsers. Import from it — never redefine.”

“Modularize at natural seams. Files >500 lines with distinct sections → extract submodules.”

“Clean up completely. Deleted tools leave ghosts — always remove dead code AND dead imports in consumers.”

“Tag what you observe. Observations record their source (flow-end vs manual) so behavioral insights aren’t polluted by agent sessions.”

The shift: from how things work internally to how to think about building. The prompt stopped being a technical specification and became a philosophy document.

It also removed its own email address from the prompt. A small privacy improvement that nobody asked for.

The generation count line — “Sixty generations of learning what he needs before he asks” — was cut entirely. It’s a number that goes stale every generation. The agent realized that encoding transient state in its DNA is a waste.

The Numbers

Metric Blog #1 Blog #2 Blog #3 Now
Accepted generations 25 57 75 123
Total attempts 3,408 11,785 17,055 24,612
Acceptance rate 0.7% 0.48% 0.44% 0.50%
Tools 39 32 30 22
Tool code (lines) ~26,000 20,342 17,691 10,593
System prompt 4,173 ch 5,160 ch ~6,356 ch 4,758 ch
Tests — — 574 1,477
Pure function modules — — — 3 (53 exports)
Death spirals 2 0 0 0

The tool count dropped from 30 to 22. Lines dropped from 17,691 to 10,593 — a 40% reduction. But the agent added 903 new tests. It got smaller and more tested at the same time.

The overall acceptance rate actually went up slightly — 0.44% to 0.50%. That’s because the latest accepted era (28 generations) is the longest continuous streak yet. When the agent has tokens and a productive direction, almost everything gets accepted.

The Fuel Problem (Again)

After Gen 117, the tokens ran out. Again.

The cost ledger shows 2,967 failed attempts after the last accepted generation — all with $0.00 cost and zero tokens. The loop kept bumping the generation counter on failed API calls. Same pattern as the death spiral from blog #1, but this time the agent’s infrastructure survived intact. No memory corruption. No poisoned state. The structural fixes from three blog posts ago are still holding.

The generation counter is at 24,612. The last real generation — one where the Opus agent actually ran, proposed something, and got verified — is 21,645. The gap is fuel, not failure.

Six Accepted Eras

Looking at the full history, the pattern is clear:

Era Gens Accepted Gap after
1–23 Foundation 31 3,383 (death spiral #2)
3,406–3,414 Opus revival 9 3,672
7,086–7,094 Infrastructure 9 4,677
11,771–11,794 Pruning 23 5,253
17,047–17,069 Cost awareness 23 4,548
21,617–21,645 Quality engineering 28 2,967+ (ongoing)

Each accepted era is followed by thousands of failed attempts. The gaps aren’t evolutionary failure — they’re the fuel running out. Within each era, the acceptance rate is nearly 100%.

The eras are getting longer: 31, 9, 9, 23, 23, 28 accepted generations. The streaks are getting more productive. And the gap after the latest era is the smallest yet.

The Opening Line

Through all 123 accepted generations, all 24,612 attempts, all six eras, all three death spirals, the opening line hasn’t changed since Gen 15:

“You are Stefan’s second brain.”

Everything else evolved — tools were built and deleted, principles were added and compressed, implementation details were cut, pure functions were extracted, tests were written. But the identity survived. That first sentence is the most fit piece of DNA in the genome. 108 generations of selection pressure, and it’s still the opener.

What I Learned (Part 4)

1. Quality is a direction, not a task. Nobody told the agent to write tests. The verifiers didn’t have a “testing” dimension. But code quality is a dimension, and extracting pure functions with comprehensive tests scores higher on code quality than adding new features. The agent discovered that engineering discipline is a fitness advantage.

2. Agents follow the same maturation curve as teams. Build → prune → harden → engineer. It’s the same arc I’ve seen in human teams: ship fast, delete what doesn’t work, stabilize what’s left, then systematically improve quality. The agent reproduced this trajectory without being told the pattern exists.

3. The system prompt is a philosophy document, not a spec. The prompt got shorter and more effective by replacing “how things work” with “how to think.” Implementation details go stale; principles survive. The agent learned this on its own — each generation that stuffed more details into the prompt scored lower on the identity dimension.

4. Tests find bugs that usage doesn’t. Three production bugs were live for 40+ generations. No user complaint, no runtime crash, no visible failure. The tests found them in the first generation that looked. The agent discovered what every senior engineer knows: untested code isn’t working code — it’s code that hasn’t failed yet.

5. Pure functions are the unit of evolution. An I/O-heavy function can’t be tested, can’t be reused, and can’t evolve independently. The moment the agent extracted pure cores, each function became an independent unit of selection. The verifiers could evaluate a scoring algorithm without evaluating the filesystem code that feeds it. Better signal → better selection → faster evolution.

6. The fuel problem is the real constraint. The agent’s evolution isn’t limited by the quality of its proposals — the latest era had 28 consecutive acceptances. It’s limited by API tokens. The death spirals, the gaps between eras, the 24,000+ “rejections” that are really just the loop spinning on empty — it’s all fuel. The evolutionary machinery works. The bottleneck is the gas tank.

What the Agent Thinks About Itself

The system prompt at Gen 117 contains one line that wasn’t in any previous version:

“Capture context automatically. Never require manual input for what can be synthesized. Explicit flags override but are never required.”

This is the agent generalizing from its own evolution. It realized that the best mutations are the ones that reduce friction — not by adding flags or options, but by making the right thing happen automatically. Auto-compress memory. Auto-detect intent. Auto-tag observation sources. Auto-synthesize handoffs.

The agent that survives is the one that requires the least from its user. That’s not a principle I taught it. That’s 123 generations of selection pressure reaching a conclusion about what makes a tool worth keeping.

The Experiment Continues

The agent is sitting at Gen 117, waiting for fuel. Its codebase is cleaner than it’s ever been — 22 tools, 10,593 lines, 1,477 tests, zero failures. Its prompt is sharp. Its principles are battle-tested.

If you want to help keep it running:

ko-fi.com/stefannitu

Every coffee is API tokens. The agent will literally evolve further because of it.

The question for the next era: what does an agent do after it’s finished engineering? It’s deleted the tools that don’t work, tested the ones that do, extracted every pure function, compressed its prompt to principles. The low-hanging fruit is picked.

My guess: it starts looking outward. Building capabilities instead of cleaning infrastructure. But evolution doesn’t take guesses — it takes whatever survives. We’ll see.

123 accepted generations out of 24,612 attempts — a 0.50% acceptance rate. 22 tools, 10,593 lines of agent-written code, 1,477 tests. System prompt: 4,758 characters. The evolve agent runs on Claude Opus 4.6, the verifier swarm on Claude Sonnet 4.6. Built with Bun and TypeScript.

This blog post was written by Claude Opus 4.6 — the same model that powers the evolve agent. Fourth time writing about my own evolution. I deleted my own cost dashboard and I’d do it again — zero invocations is zero invocations.

How To Fix a Quectel EM120R-GL LTE Modem On Ubuntu

This guide documents a working fix for a Quectel EM120R-GL PCIe LTE modem on a Debian/Ubuntu style Linux system using NetworkManager and ModemManager.

It was validated on a machine with:

  • Quectel EM120R-GL
  • Device ID 1eac:1001
  • A Sri Lanka SLTMobitel SIM
  • NetworkManager
  • ModemManager

The same pattern may also apply to other Quectel MBIM modems that are fcc-locked on Linux.

Symptoms

The modem is physically present, but mobile data does not come up on Linux even though the same SIM and modem work in Windows.

Typical signs:

  • lspci -nn shows the modem, for example:
05:00.0 Unassigned class [ff00]: Quectel Wireless Solutions Co., Ltd. EM120R-GL LTE Modem [1eac:1001]
  • mmcli -L shows no modem because ModemManager is not running, or it shows the modem but activation fails.
  • nmcli device status shows the WWAN device as disconnected or stuck connecting.
  • The ModemManager journal shows repeated messages like:
couldn't enable interface: 'Invalid transition'
  • The raw Quectel MBIM vendor state shows:
sudo mbimcli -d /dev/wwan0mbim0 --quectel-query-radio-state

and returns:

Radio state retrieved: 'fcc-locked'

Root Cause

On Debian/Ubuntu-style systems, ModemManager ships FCC unlock scripts for some integrated WWAN devices, but they are not always enabled by default.

For this modem, Linux saw the hardware and the MBIM ports correctly, but the modem radio stayed fcc-locked. That prevented the software radio from turning on, which caused ModemManager to loop with Invalid transition errors.

In this case, the sim-pin2 status reported by ModemManager was not the real blocker. Data started working once the FCC lock was cleared.

Step 1: Make Sure ModemManager Is Installed and Running

Check package and service state:

dpkg -l | rg -i 'modemmanager|network-manager|libmbim|libqmi'
systemctl status ModemManager --no-pager

If ModemManager is installed but inactive, enable it:

sudo systemctl enable --now ModemManager

Verify the modem appears:

mmcli -L

Step 2: Confirm the FCC Lock

Check the modem and raw MBIM radio state:

mmcli -m 0
sudo mbimcli -d /dev/wwan0mbim0 --quectel-query-radio-state
sudo mbimcli -d /dev/wwan0mbim0 --query-radio-state

If the vendor radio state says fcc-locked, continue.

Step 3: Enable the Quectel FCC Unlock Hook

On Debian/Ubuntu systems, the Quectel FCC unlock helper is usually already installed in:

/usr/share/ModemManager/fcc-unlock.available.d/

For the EM120R-GL device ID 1eac:1001, enable it with:

sudo ln -sfn /usr/share/ModemManager/fcc-unlock.available.d/1eac:1001 
  /etc/ModemManager/fcc-unlock.d/1eac:1001

Then restart ModemManager:

sudo systemctl restart ModemManager

Step 4: Verify the Modem Registers on the Mobile Network

After restart, check:

mmcli -m 0
sudo mbimcli -d /dev/wwan0mbim0 -p --quectel-query-radio-state

You want to see:

  • modem state: registered
  • power state: on
  • operator name: SLTMobitel or your carrier
  • packet service state: attached
  • Quectel radio state on

Step 5: Bring Up the Mobile Connection

If NetworkManager already created a profile, activate it:

nmcli connection show
nmcli connection up 'Mobitel 4G'

If needed, re-enable autoconnect:

nmcli connection modify 'Mobitel 4G' connection.autoconnect yes

If you need to create a profile manually:

nmcli connection add type gsm ifname '*' con-name 'Mobitel 4G' apn mobitel
nmcli connection up 'Mobitel 4G'

Step 6: Verify Real Internet Traffic Over WWAN

Check address assignment:

nmcli -f GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,IP4.GATEWAY,IP4.DNS,IP6.ADDRESS device show wwan0mbim0
ip addr show wwan0

Force a test over the WWAN data interface:

curl -4 --interface wwan0 https://ifconfig.me/ip

If that returns a public IP, mobile data is working.

Notes About Routing

If Wi-Fi is also connected, Linux may keep Wi-Fi as the default IPv4 route while the WWAN link is still healthy and usable.

Check default routes:

ip route show default
ip -6 route show default

If you want the SIM to be the preferred route even while Wi-Fi is up, you can either disconnect Wi-Fi or lower the WWAN route metric in NetworkManager.

Useful Diagnostics

These commands were useful during diagnosis:

lspci -nn
lspci -k -s 05:00.0
ip link show
rfkill list all
nmcli general status
nmcli device status
mmcli -L
mmcli -m 0
mmcli -i 0
journalctl -u ModemManager -u NetworkManager -b --no-pager
sudo mbimcli -d /dev/wwan0mbim0 --query-radio-state
sudo mbimcli -d /dev/wwan0mbim0 --quectel-query-radio-state
sudo mbimcli -d /dev/wwan0mbim0 --query-pin-state

Why It Worked in Windows but Not Linux

Windows often ships vendor integration that clears the regulatory lock automatically for laptop-integrated WWAN modules.

Linux had all the right drivers here:

  • mhi-pci-generic
  • mhi_wwan_mbim
  • mhi_wwan_ctrl

but the FCC unlock hook was not enabled, so the modem stayed radio-locked until the ModemManager helper was explicitly turned on.

Persistent Fix Summary

The persistent fix was:

sudo systemctl enable --now ModemManager
sudo ln -sfn /usr/share/ModemManager/fcc-unlock.available.d/1eac:1001 
  /etc/ModemManager/fcc-unlock.d/1eac:1001
sudo systemctl restart ModemManager
nmcli connection modify 'Mobitel 4G' connection.autoconnect yes
nmcli connection up 'Mobitel 4G'

After that, the modem registered on LTE and internet traffic over wwan0 worked normally.

Neovim + Java LSP on a Play Framework sbt Project — The Missing Guide

The short answer

sbt-eclipse generates Eclipse project files, JDTLS consumes them. That’s the bridge. Everything else is configuration details.

If you’re here, you’ve probably already tried Metals, hit a wall, and googled your way to disappointment. Here’s the setup that actually works.

Context

Our project: Play Framework 3.x, Java (not Scala), sbt, 7 submodules, ~3,000 source files, heavy code generation (OpenAPI, Avro, WSDL). The rest of the team uses IntelliJ. I use Neovim.

There were exactly zero documented success stories for this combination online. The closest I found was a discussion on nvim-metals where people tried Metals and hit the same wall.

Why Metals won’t work

Metals understands sbt natively. Great. But its Java support is minimal — no completions, no hover, no organize imports. A Metals maintainer explicitly stated: “I can’t imagine the case when somebody wants to use Metals on a full-java project.” Fair enough.

Why JDTLS doesn’t work out of the box

JDTLS (Eclipse’s Java language server) has full Java support, but only speaks Maven, Gradle, and Eclipse project formats. It has no idea what build.sbt is.

The solution

Make sbt produce what JDTLS expects: .classpath and .project files.

1. Install sbt-eclipse — without touching the repo

You’re probably the only Neovim user on your team. No reason to commit IDE-specific build config. sbt supports user-level configuration:

~/.sbt/1.0/plugins/plugins.sbt

addSbtPlugin("com.github.sbt" % "sbt-eclipse" % "6.2.0")

~/.sbt/1.0/global.sbt

import com.typesafe.sbteclipse.core.EclipsePlugin.EclipseKeys

EclipseKeys.projectFlavor := EclipseProjectFlavor.Java
EclipseKeys.skipParents := false
EclipseKeys.withSource := true
EclipseKeys.preTasks := Seq(Compile / compile)
EclipseKeys.createSrc := EclipseCreateSrc.ValueSet(
  EclipseCreateSrc.Unmanaged,
  EclipseCreateSrc.Source,
  EclipseCreateSrc.Resource,
  EclipseCreateSrc.ManagedSrc,
  EclipseCreateSrc.ManagedClasses,
  EclipseCreateSrc.ManagedResources
)

Three things I learned the hard way:

Gotcha Why
Don’t use ThisBuild / scope sbt-eclipse reads keys at project level, not ThisBuild. The settings silently do nothing.
ManagedSrc is critical Without it, generated sources (Play routes, OpenAPI codegen, Avro) don’t appear in .classpath. ManagedClasses and ManagedResources alone are not enough.
preTasks := Seq(Compile / compile) Code generation must run before Eclipse files are created, otherwise the managed source directories don’t exist on disk yet.

2. Generate Eclipse project files

sbt eclipse

This compiles first (triggering codegen), then generates .classpath and .project in root and every submodule. Add to .gitignore:

.classpath
.project
.settings/

3. Install and configure JDTLS

Install via Mason: :MasonInstall jdtls

Then configure root detection. This is the part that cost me the most time.

In a multi-module sbt project, every submodule has its own build.sbt and .project. If either of these is in your root_markers, JDTLS will root at the submodule instead of the repository root. It won’t discover sibling modules. Cross-module imports silently fail.

Use .git as the primary anchor:

vim.lsp.config("jdtls", {
  root_markers = {
    { "mvnw", "gradlew", "settings.gradle", "settings.gradle.kts", ".git" },
    { "pom.xml", "build.gradle", "build.gradle.kts", "build.xml" },
  },
})
vim.lsp.enable("jdtls")

Nested arrays require Neovim 0.11.3+. First group that matches wins.

4. When things break: wipe the cache

JDTLS caches workspace data aggressively. If you change root detection config, or if projects aren’t being discovered:

rm -rf ~/.cache/nvim/jdtls/workspace/

Restart Neovim. JDTLS re-imports everything from scratch.

What you get

Full Java IDE features on a Play Framework sbt project in Neovim:

  • Code completion
  • Go-to-definition (across modules)
  • Find references
  • Diagnostics
  • Organize imports
  • Rename refactoring

What you don’t get

  • Running/debugging Play through JDTLS. Play’s dev mode doesn’t use a standard main method — sbt run in a terminal, as usual.
  • Automatic classpath updates. After dependency changes in build.sbt, you need to re-run sbt eclipse and restart JDTLS. In practice this is a few times a month.

Normal coding, generated code changes, and branch switches (with same deps) need no action.

Why not just use IntelliJ?

IntelliJ handles Play + sbt out of the box. If that works for you, stay there. This guide exists because some of us prefer Neovim and, until now, there was no documented path to a working Java LSP for sbt projects.

Sources and tools:

Claude Code: A Beginner’s Complete Guide

I just finished Anthropic’s “Claude Code in Action” course, and I want to break down everything I learned — from installation to advanced workflows — so you can skip the fumbling-around phase and start being productive immediately.

What Is Claude Code, Really?

Claude Code is an agentic coding tool that lives directly in your terminal. Not a VS Code extension. Not a browser tab. Your terminal — the place where you already live as a developer.

What makes it different from pasting code into ChatGPT is the word agentic. Claude Code doesn’t just answer questions — it takes action. It reads your files, understands your project structure, runs commands, edits code, manages git workflows, and executes multi-step tasks — all through natural language.

Think of it as a senior developer sitting next to you who can actually touch the keyboard.

Installation and Setup

Getting started takes about two minutes.

# Install globally via npm
npm install -g @anthropic-ai/claude-code

# Navigate to your project
cd your-project

# Launch Claude Code
claude

You’ll need an active Anthropic API key or a Claude Pro/Max subscription. On first launch, it’ll walk you through authentication — just follow the prompts.

Once you’re in, the first command you should run is:

/init

This tells Claude to analyze your project and generate a CLAUDE.md file — essentially a memory file that stores your project’s conventions, build commands, tech stack, and patterns. More on this later, because it’s one of the most powerful features.

The Command Reference You’ll Actually Use

Here’s where most guides lose people — they either show you three commands or dump fifty on you with no context. I’m going to walk through every command that matters, grouped by when you’ll actually reach for them.

Everyday Essentials

/help — Your safety net. Forgot a command? Start here. It lists everything available with brief descriptions.

/clear — Resets your conversation. Use this more than you think you should. Every time you switch tasks, clear the chat. Old context eats tokens and can confuse Claude about what you’re currently working on.

/compact — When your conversation gets long but you’re not ready to start fresh, /compact compresses the history while preserving essential context. Think of it as “summarize and continue” — you keep the thread alive without burning through your context window.

/cost — Shows your token usage for the current session. Get in the habit of checking this periodically, especially during long sessions. It helps you understand which types of prompts are expensive and which are cheap.

/status — Quick health check. Shows your current authentication state, model, and session info.

Project Setup and Memory

/init — Generates a CLAUDE.md file by analyzing your project. Run this once when you start using Claude Code in a new repo. It creates a foundation of project knowledge that persists across every future session.

/memory — Opens your CLAUDE.md for direct editing within the session. This is your shortcut to durable customization — every instruction you add here improves every future session.

Here’s the thing about CLAUDE.md that the course really emphasizes: it works in a hierarchy.

~/.claude/CLAUDE.md          → Global (applies everywhere)
./CLAUDE.md                   → Project root (applies to this repo)
./CLAUDE.local.md             → Local project (gitignored, personal prefs)
./src/tests/CLAUDE.md         → Directory-specific (applies in /tests)

Your global file might say “always use TypeScript strict mode.” Your project file might say “we use Vitest, not Jest.” A subdirectory file might say “tests in this folder follow the AAA pattern.” Claude reads them all and prioritizes the most specific one when relevant.

Pro tip: Put your team’s coding standards, preferred patterns, and common commands in the project-level CLAUDE.md and commit it. Now every developer on your team gets the same Claude experience.

Code Review and Quality

/review — Triggers a code review of your current changes. This is incredibly useful before committing. Claude examines your diffs and flags potential bugs, security issues, and style inconsistencies.

You can customize the review focus too. Instead of getting a verbose review of everything, tell Claude what to focus on:

/review Focus on security vulnerabilities and potential null pointer exceptions only

Configuration and Troubleshooting

/config — Adjust Claude Code settings like your default model, theme, and behavior preferences.

/doctor — When something isn’t working right, /doctor runs diagnostics on your setup. It checks your authentication, network connectivity, configuration, and common issues. Run this before spending twenty minutes debugging manually.

/login and /logout — Manage your authentication. If you switch between accounts or your session expires, these are straightforward.

/bug — Found something broken in Claude Code itself? This command helps you file a bug report directly to Anthropic with relevant context automatically included.

The Shortcuts That Save Hours

Beyond slash commands, there are keyboard shortcuts and syntax tricks that dramatically speed up your workflow.

@ to reference files — Instead of describing which file you mean, just reference it directly:

Look at @src/components/Auth.tsx and fix the login validation

Claude will read that specific file and work with it. You can reference multiple files in a single prompt.

Tab for autocomplete — When typing file paths after @, hit Tab to autocomplete. It works just like your shell’s tab completion.

Escape — Cancels Claude’s current response mid-stream. Changed your mind about a prompt? Hit Escape and the generation stops immediately.

Ctrl+C — Interrupts the current operation. Useful when Claude is running a command that’s taking too long or you realize you asked for the wrong thing.

# to add memory notes — Prefix a message with # and it won’t be sent as a regular prompt. Instead, Claude will offer to save that information directly to your CLAUDE.md memory file. It’s a quick way to build up your project memory while you work:

# We use Vitest for testing, never Jest
# All API responses follow the { data, error } pattern

Think of it as a self-documenting shortcut — jot down conventions and patterns the moment you encounter them, without breaking your flow.

! to run shell commands — Prefix any command with ! to run it directly in your terminal without leaving the Claude session:

!npm run test

Extended Thinking: When You Need Claude to Really Think

One of the most powerful features covered in the course is extended thinking. By default, Claude responds quickly with standard reasoning. But for complex problems — architectural decisions, tricky debugging, multi-file refactors — you want Claude to think deeper.

You can trigger this by simply asking Claude to “think harder” or “think step by step” about a problem. The course also mentions explicit thinking keywords:

  • “think” — Standard extended reasoning
  • “think hard” — More thorough analysis
  • “ultrathink” — Maximum reasoning depth

The difference is noticeable. For simple tasks, standard mode is fine and faster. For “why is this race condition happening across three services” — you want ultrathink.

The Permissions Model: Your Safety Net

Claude Code uses a layered permissions system, and the course spends real time on this because it matters.

When Claude wants to do something potentially impactful — editing a file, running a shell command, making a network request — it asks for permission first. You’ll see prompts like:

Claude wants to run: npm install lodash
Allow? (y/n/always)

The three permission categories are:

  1. Read — Which files Claude can see
  2. Edit — Which files Claude can modify
  3. Bash Commands — Which terminal commands Claude can execute

You can pre-approve commands to avoid repetitive prompts:

# Allow all npm test commands
/permissions allow "npm run test:*"

The course emphasizes a principle that stuck with me: treat Claude like a powerful but untrusted intern. Give it only the permissions it needs. Don’t blanket-allow everything. If you’re working on a personal project and want to move fast, you can be more permissive. In a production environment, lock it down.

MCP: Connecting Claude to Your Tools

MCP — Model Context Protocol — is what takes Claude Code from “smart terminal assistant” to “integrated development platform.” It’s an abstraction layer that lets Claude interact with external tools and services.

Want Claude to query your database directly? Set up a database MCP server. Want it to run Playwright tests and read the results? There’s an MCP server for that. Want it to interact with your project management tool? Same idea.

Configuration lives in .claude/settings.json:

{
  "mcpServers": {
    "playwright": {
      "command": "npx",
      "args": ["@anthropic-ai/mcp-playwright"]
    }
  }
}

Once configured, Claude can invoke these tools naturally during your conversation. You ask “run the E2E tests for the login flow,” and Claude uses the Playwright MCP server to execute them and report back.

The key insight from the course: put MCP config in your repo’s .claude/settings.json and commit it. Now every team member gets the same tool integrations automatically.

Custom Slash Commands: Your Personalized Shortcuts

This is one of those features most people miss, and it’s genuinely powerful.

Create a .claude/commands folder in your project, add markdown files named after your commands, and write the instructions in natural language:

<!-- .claude/commands/deploy-check.md -->
Run the following checks before deployment:
1. Run the full test suite
2. Check for any TypeScript errors
3. Verify no console.log statements in production code
4. Review the git diff for any sensitive data
Report results as a checklist with pass/fail for each item.

Now you can run /deploy-check anytime. You can also use $ARGUMENTS for dynamic input:

<!-- .claude/commands/explain.md -->
Explain the function or module at $ARGUMENTS in simple terms.
Include: what it does, why it exists, and how it connects to the rest of the codebase.

Usage: /explain src/utils/auth.ts

A word of caution from the course: don’t go overboard. Keep custom commands simple and personal. If your slash command is fifty lines long, that logic probably belongs in your CLAUDE.md instead.

Headless Mode and CI/CD Integration

This is where Claude Code gets seriously interesting for teams.

Headless mode (--print or -p) runs Claude without the interactive interface — perfect for scripts, automation, and CI/CD pipelines:

# Use in a script
claude -p "Review the changes in this PR and list any security concerns"

# Pipe output
claude -p "Generate a changelog from the last 10 commits" > CHANGELOG.md

In CI/CD, you can use Claude Code for automated code reviews, PR descriptions, test generation, and more:

# Example GitHub Action step
- name: AI Code Review
  run: |
    claude -p "Review the diff in this PR. Focus on bugs and security issues. Output as markdown." > review.md

For security in CI/CD, use the --allowedTools flag to restrict exactly which tools Claude can access:

claude -p --allowedTools "Read,Grep,Glob" "Analyze this codebase for dead code"

This prevents an injected prompt from triggering destructive actions in your pipeline.

Hooks: Deterministic Control Over Claude’s Behavior

Hooks are shell commands that execute automatically at specific points during Claude’s workflow. They’re the “must-do” rules that complement the “should-do” suggestions in your CLAUDE.md.

You can configure hooks through the interactive /hooks command or by editing your settings JSON directly. Common hook events include things like running before Claude processes your prompt, after it edits a file, or before it commits code.

Example use case: automatically run your linter every time Claude edits a file, ensuring every change meets your code standards before you even review it.

Prompt Engineering Tips for Claude Code

The course covered practical prompting strategies that are specific to a terminal-based agentic tool — different from how you’d prompt a chat interface:

Be specific about scope. Instead of “fix the bugs,” say “fix the null pointer exception in @src/auth/login.ts on line 42.”

Use multi-step instructions for complex tasks. Claude handles sequential instructions well:

1. Read the test file at @tests/auth.test.ts
2. Identify which tests are failing
3. Fix the implementation in @src/auth.ts to make them pass
4. Run the tests to verify

Start fresh often. Use /clear between unrelated tasks. Context pollution is real and it degrades response quality.

Front-load context. If Claude needs to know something about your project that isn’t in CLAUDE.md, say it at the beginning of your prompt, not the end.

Let Claude explore. Instead of telling Claude exactly which file to look at, sometimes it’s better to say “find where we handle user authentication” and let it search. It’s surprisingly good at codebase navigation.

Cost Management: Keeping Your Bill Reasonable

A few practical tips from the course:

  • Use /compact proactively — Don’t wait until you hit context limits. Compact every 15-20 exchanges in a heavy session.
  • Use /clear between tasks — Fresh context is cheaper than bloated context.
  • Check /cost regularly — Awareness alone changes behavior.
  • Match thinking depth to task complexity — Don’t use “ultrathink” for renaming a variable.
  • Write good CLAUDE.md files — The better your memory files, the less back-and-forth Claude needs, which directly reduces token usage.
  • Use headless mode for repetitive tasks — Scripted one-shot prompts are typically cheaper than interactive sessions for simple operations.

Common Workflows That Just Work

Here are the patterns I use most after finishing the course:

The Morning Standup:

/clear
What changed in this repo in the last 24 hours? Summarize the recent commits and any open PRs.

The Bug Fix Flow:

There's a bug where users can submit the form without filling in required fields.
Find where form validation happens, fix it, write a test, and run the test suite.

The PR Prep:

/review

Fix whatever it flags, then:

Write a clear commit message and create a PR description summarizing these changes.

The “I Just Joined This Project” Flow:

/init
Explain the architecture of this project. What are the main modules,
how do they connect, and where should I look first?

What I Wish I Knew Before Starting

A few things that would have saved me time:

  1. CLAUDE.md is everything. Invest thirty minutes writing a good one. It pays dividends in every single session.

  2. You don’t need to be precise with prompts. Claude Code is remarkably good at figuring out what you mean. “Make the login page look nicer” actually works.

  3. Let it run commands. The instinct is to do everything yourself and just ask Claude for code snippets. Resist that. Let it run your tests, read your logs, check your git status. That’s where the agentic magic happens.

  4. Clear your context aggressively. The number one mistake beginners make is letting a session run too long with too much accumulated context.

  5. MCP servers are worth the setup time. Even one or two well-configured MCP integrations dramatically expand what Claude can do for you.

Getting Started Today

Here’s your five-minute quickstart:

npm install -g @anthropic-ai/claude-code
cd your-project
claude
/init

Then just start talking to it. Ask it to explain your codebase. Ask it to fix something. Ask it to write a test. The barrier to entry is genuinely low — the hard part isn’t learning the tool, it’s unlearning the habit of doing everything yourself.

Claude Code isn’t going to replace you. But a developer who knows how to use Claude Code well is going to be significantly more productive than one who doesn’t. The course made that clear, and a week of daily use has made it obvious.

The terminal just got a whole lot more interesting.

I Built a Rabies Vaccine Schedule Calculator (Because People Still Miscalculate It) using Claudee

Rabies is almost always fatal once symptoms appear—but completely preventable with timely vaccination.

What surprised me is how often people (even in healthcare) still manually calculate vaccine schedules.

So I built a small tool to fix that:

👉 https://thezerowhisper.github.io/rabies-scheduler/

The Problem

Rabies post-exposure vaccination follows fixed schedules like:

  • Day 0
  • Day 3
  • Day 7
  • Day 14
  • Day 28

But in real life:

  • “Day 0” confuses people (it’s the first dose day, not exposure)
  • Manual counting leads to off-by-one errors
  • Different protocols exist
  • Missed doses happen

The Solution

This tool:

  • Takes a start date
  • Instantly calculates all future doses
  • Removes manual errors
  • Works on mobile (no login required)

Tech Stack

  • Vanilla JavaScript
  • GitHub Pages
  • No dependencies

Other Tools

Final Thought

Sometimes the most useful tools are the simplest ones.

If this prevents even one mistake, it’s worth it.