The job of an AI engineer inside a 40-person company is not what most CEOs think it is

CEOs of 40-person companies often imagine their first AI hire will be a model trainer—someone who builds custom neural networks from scratch. Reality check: that’s maybe 10% of the job. The real work is less glamorous but more critical. Most days are spent cleaning data, managing third-party APIs, and translating between technical teams and business stakeholders. The actual model training happens mostly in vendor platforms like OpenAI or Anthropic. The real value is in connecting those tools to your actual operations.

Let’s walk through three typical weeks in the life of an AI engineer at a small company.

Week one: Data plumbing. The team needs to connect customer support tickets to knowledge base articles. The engineer spends four days writing scripts to extract text from Zendesk, clean it of HTML tags, and structure it for retrieval. The fifth day goes to testing edge cases—what happens when a ticket has no subject line? Or when the article contains a table? No model training happens. Just making sure the input pipeline works.

Week two: Vendor management. The company wants to use OpenAI’s API for summarization. The engineer evaluates three pricing tiers, tests rate limits, sets up monitoring for API failures, and writes fallback logic when the service is slow. They spend two days documenting the quirks—how the API handles technical jargon, its tendency to hallucinate dates. Then they build a wrapper that handles all this complexity so the business team doesn’t need to think about it.

Week three: Translation. Sales wants a tool to draft follow-up emails. The engineer sits with the sales team for three days to understand their actual workflow—what information they need to include, what tone to use, how they handle objections. They build a prompt library that maps to different scenarios, then create a simple interface where sales reps can input key details and get drafts. The heavy lifting isn’t in the model—it’s in making sure the output matches how salespeople actually work.

So when you’re hiring for this role, don’t look for deep learning expertise. Look for someone who can build data pipelines, understand API limitations, and translate business needs into technical requirements. The best candidates have experience with ETL tools, cloud services, and have worked with non-technical teams. They should be able to explain trade-offs clearly—why a certain approach might be slower but more reliable, or how to handle missing data. The magic isn’t in the models themselves—it’s in making them work in your messy, real-world environment.

This piece is from our notes on helping SMBs (10-100 people) build their first in-house AI teams. If your team is exploring this — quick feedback and questions welcome in the comments.

Building a Clinical Speech-Therapy App With a Real SLP: 4 Lessons From PhoenixSteps

Originally published on the AstroLexis blog. Cross-posted here for the community.

My son’s speech-language pathologist became my co-founder. PhoenixSteps is what came out of it: a pediatric clinical app that does what existing apps don’t because we built it together — engineer plus therapist plus actual patient (also my kid). Here are four lessons from the last six months, including how we taught Apple’s Vision framework to do something Apple flatly refused to.

How this started

My son has a speech sound disorder. Specifically, rotacismo — he struggles with /r/ and /rr/, which in Spanish are foundational phonemes that show up in roughly one in every six words. His speech-language pathologist is Stefania. We’ve been seeing her weekly for over a year and the progress has been real, but inconsistent: he’d nail a sound during a session and lose it by mid-week.

The gap was obvious to both of us. He’d do exercises with Stefa for forty minutes, then we’d go home and the exercises mostly stopped, because:

  • The “drill at home” sheet Stefa sent had no feedback loop. My kid would say “ratón” five times and have no idea if any of them were correct.
  • Existing pediatric speech-therapy apps in Spanish are either commercially mediocre (gamified versions of basic flashcards) or clinically rigid (built for adult speech rehab, not children).
  • The market for tools that actually run the clinical exercises a Spanish-speaking SLP would prescribe — with audio feedback, automatic scoring, and progress tracking the therapist can read — basically did not exist for a private practice working with a 4-year-old.

I asked Stefa if she’d want to co-design something. She said yes. That’s how PhoenixSteps started — and the four lessons below are the ones I wish I’d known going in.

Lesson 1: A clinical co-creator changes everything about what you ship

I had built consumer iOS apps before. I had not built a clinical tool. The thing I underestimated was how much of the actual product is the protocol, not the software.

Stefa works from named, published clinical protocols — Borrás, Bosch, the AELFA articulation drills. When she prescribes an exercise, she’s pulling from a tradition that has decades of consensus on order, dosage, and progression. “Lengua a la nariz” isn’t a cute idea — it’s Borrás Exercise 29, with specific instructions about duration, repetitions per day, and what to do if the child can’t sustain the position.

Before working with Stefa, I would have built a “speech therapy app” that was basically a glorified flashcard deck with cute animations. With Stefa, the exercise catalog became:

  • Orofacial praxias — 7 exercises pulled directly from her clinical sheet, in the order she actually prescribes them.
  • R-group syllable warmups — “ra ra ra,” “rrrr-on” — building muscle memory before tackling words.
  • R simple words — rosa, ratón, mira, perro — graded by Stefa for difficulty.
  • R-cluster words (sinfones) — bra, cra, dra, fra, gra, pra, tra. The hard ones.
  • Minimal pairs — R/RR, R/L, D/R, T/D. Auditory discrimination drills.
  • Carrier phrases — embedding the target sound in real sentences.
  • “Tren de la Risa” — a karaoke song Stefa wrote that hits every R context across 8 verses.

None of that comes out of an engineer’s imagination. It comes out of a working SLP’s notebook.

Lesson 1 distilled: if you’re building a clinical product, the clinician is not a “domain advisor.” They’re a co-founder. Hire them, equity them in, give them a real voice on the product roadmap.

Lesson 2: Apple won’t give you what your patient needs. Build it yourself.

This is the technical story, and it’s the one I’m most proud of.

One of the most prescribed praxias for kids working on /r/ is “lengua a la nariz” — extending the tongue tip toward the nose. The exercise builds the lingual elevation needed for the alveolar trill. Stefa wants the app to automatically verify the kid did the exercise correctly: tongue out, pointed up, sustained for 10 seconds.

This sounds like a job for ARKit. Apple has had face tracking with the TrueDepth camera since the iPhone X. ARFaceAnchor.blendShapes includes jawOpen, mouthSmileLeft, cheekPuff — and yes, tongueOut.

Except: tongueOut is a scalar. It’s 0 when the tongue is in, and 1 when it’s out. Apple does not tell you where the tongue is pointing. Up, down, left, right — they all read identical.

I emailed Apple developer support. The answer was: no, the tongue is not modeled as 3D geometry, and there’s no API to detect tongue direction. Tongue tracking is inherently unstable (occlusion by teeth and lips), so Apple chose not to ship something they couldn’t validate at Face ID precision.

So Stefa and I built the detector ourselves.

The pipeline

  1. ARKit captures the camera frame on the TrueDepth camera at 60 fps.
  2. We grab the raw frame.capturedImage — the YUV pixel buffer ARKit hands you for free.
  3. Vision detects face landmarks: VNDetectFaceLandmarksRequest returns outerLips, innerLips, and nose as 2D polygons.
  4. Three Regions of Interest outside the lip polygon:

    • UP ROI — rectangle between top of upper lip and bottom of nose
    • LEFT ROI — extending leftward from the left corner of the lips
    • RIGHT ROI — same, mirrored
  5. Count pink/red pixels inside each ROI. The lip-skin transition is at Cr ≈ 18; the tongue is at Cr ≈ 25-50. We threshold Cr > 25 to filter out facial skin and pale lips.
  6. If a ROI has > 400 “tongue-colored” pixels, the tongue is projecting in that direction. Cross-check with ARKit’s tongueOut blendshape, mirror-compensate for the front-facing camera.

The detector reports up, down, left, right, center, or notVisible at 20Hz with a confidence score. The first time I showed Stefa the demo — me sticking my tongue toward my nose and watching the screen say “ARRIBA conf 100% pix 3,974” — she didn’t believe it was real until I sent her the source code.

Lesson 2 distilled: the most defensible technical work in a clinical product is the part Apple won’t ship. If you can do something the platform doesn’t expose — and it matters for the clinical outcome — that’s your moat.

Lesson 3: Audio quality is a feature, not a detail

PhoenixSteps ships with about 325 pre-recorded voice prompts, all generated using OpenAI’s gpt-4o-mini-tts with the “nova” voice. Why pre-recorded TTS instead of letting iOS synthesize on the fly?

  • Pediatric voice consistency. Kids learn faster when the audio prompt sounds the same every time.
  • Speed and articulation. Stefa wanted slower-than-normal pronunciation for warmups, regular pace for practice, a specific cadence for the song. Generating with explicit instructions (“habla en español neutro latinoamericano, ritmo lento y articulado, énfasis infantil sin caricaturizar”) gets us the exact register a real SLP would use.
  • Reliability. Pre-recorded audio works offline, doesn’t depend on a phone’s TTS pipeline being up, doesn’t get interrupted by Siri.

We learned the hard way that the OpenAI API will occasionally return a truncated mp3 (we caught three files at 0.36s when they should have been 1.2s). The fix was a post-generation validation step: every newly generated mp3 has to pass a minimum-duration check.

Lesson 3 distilled: for pediatric/clinical apps, audio is content. Pre-render every prompt with a consistent voice and pace. Validate audio duration before bundling.

Lesson 4: HIPAA-equivalent privacy isn’t optional

The users of PhoenixSteps are children. Their voice recordings and progress data are protected health information.

  • Speech recognition on-device (WhisperKit). Voice never leaves the iPhone.
  • Face tracking on-device (ARKit + Vision).
  • Progress data in SwiftData, syncing to family’s private iCloud.
  • No analytics, no third-party SDKs, no Crashlytics, no Facebook Pixel.
  • AI features gated by parental consent. Apple Foundation Models on-device, opt-in.

PhoenixSteps will never have a data breach involving children’s voice samples, because there’s no centralized data to breach.

Lesson 4 distilled: if you’re building anything where the user is a minor or a patient, design as if the audit is happening tomorrow.

Where PhoenixSteps is right now

  • Not in the App Store yet. Build 28. Finishing the clinical pilot with Stefa.
  • Spanish-first. English localization on the roadmap once the clinical content is validated by an English-speaking SLP.
  • Free for parents, with an optional Pro tier for clinicians.
  • Stefa is a co-founder. Equity, not consulting.

If you’re an SLP working with pediatric patients in Spanish, write us. We’re going to add more clinical advisors as the product matures: [email protected].

— Bruno Galtranch, founder, AstroLexis LLC. With Stefania, SLP and co-founder.

7 overlooked .Net features

.Net is constantly evolving, a new version comes out every year, and it’s easy to lose track of some of the useful changes.
I compiled a list of 7 (mostly) recent features that I from my code reviews I think get overlooked by devs (or devs’ AI tools).

1. Extension Members (C# 14)

In addition to extension methods, we can now add extension properties and static methods to a type.
Properties are mostly just for readability, and static extension methods are useful for writing factory methods instead of creating a separate factory class.

So instead of

public static class CustomerExtensions
{
    public static bool IsPayingCustomer(this Customer customer)
    {
        return customer.TotalSpent > 0;
    }
}

we can write

public implicit extension CustomerExtensions for Customer
{
    // Add a property to a class you don't control
    public bool IsPayingCustomer => this.TotalSpent > 0;

    // Add a static method
    public static Customer Parse(string json) 
        => JsonSerializer.Deserialize<Customer>(json);
}

No state or memory, just syntactic sugar for better readability. In the background the property is still an extension method.

var customer = Customer.Parse("{"TotalSpent": 100}");

var oldMethod = customer.IsPayingCustomer();

var newProperty = customer.IsPayingCustomer;

2. The field keyword (C# 14)

It isn’t required anymore to define a backing field to have some logic in getter/setter.
Just use the field keyword (be careful not to name anything ‘field’ in the class) and get the implicit backing field.

public string Name
{
    get => field;
    set => field = value?.Trim();
}

3. File-based apps (.NET 10)

Do you have a console solution (or several) just for various small tools?
Now instead we can just run a single .cs file with dotnet run mytool.cs, without the need for any .sln or .csproj.
Write your single file like

// Add nuget packages with the special syntax
#:package Newtonsoft.Json@13.0.3

// Still need to add using statements
using Newtonsoft.Json;  

// Access command line arguments
var allArgs = Environment.GetCommandLineArgs();

4. OrderedDictionary<TKey, TValue> (.NET 9)

We now have a kind of a named list in shape of OrderedDictionary<TKey, TValue>.
The elements can be both accessed by key and by index.

var laundryRoom = rooms[17];

var diningRoom = rooms["dining"];

The order of elements is the order of insertion, so 0 is the first inserted element.

5. Enumerable.Index() (.NET 9)

A tiny but sweet sugar: instead of writing a for loop

for (int i = 0; i < items.Count; i++)
{
    var item = items[i];
    // act
}

we can use foreach with IEnumerable and still get the index variable:

foreach (var (index, item) in items.Index())
{
    // act
}

6. CountBy and AggregateBy LINQ (.NET 9)

Another sweet addition:

// old count of items by status
var countByStatus = items.GroupBy(x => x.Status)
    .Select(g => new { g.Key, Count = g.Count() });

// new
var countByStatus = items.CountBy(x => x.Status);
// old sum of amounts by status
var aggregateByStatus = items.GroupBy(x => x.Status)
    .Select(g => new { g.Key, Sum = g.Sum(x => x.Amount) });

// new
var aggregateByStatus = items.AggregateBy(
    x => x.Status,              // Key selector
    seed: 0m,                   // Initial value
    (sum, x) => sum + x.Amount  // Aggregation logic
);

Apart from just being more readable, these methods skip the intermediate grouping allocation.

7. CompareOptions.NumericOrdering (.NET 9)

Finally we can sort string and have “file2” come before “file10”, thanks to CompareOptions.NumericOrdering:

var sorted = myStrings.OrderBy(s => s,
    StringComparer.Create(CultureInfo.InvariantCulture, 
        CompareOptions.NumericOrdering));

That’s my list. Do you know other features people should use more often?

How Stripe Took 48 Hours and 3 API Calls to Break My Freelance Income Stream in Lagos

The Problem We Were Actually Solving

I freelanced full-time for a US-based crypto startup that paid monthly via PayPal. The first transfer of $6,400 arrived in March 2024. By the time the money cleared my GTBank account it was ₦5.3 million—$3,980—after PayPal took $720 in fees and the bank took another $700 for receiving charges. Worse, PayPals dashboard refused to show the FX rate they used, so I couldnt even dispute it. I calculated that over a year that bleed equaled a second studio apartment in Yaba. I needed a new rail that respected the fact that my work was global but my money had to live in Ikeja.

What We Tried First (And Why It Failed)

I started with Wise (then TransferWise). Their naira route worked only for personal use, not business. The UI blocked me when I tried to add my LLC, insisting on matching the name on my Nigerian passport. My passport reads Temilope Oke, but my company is TOKE Labs Inc. I opened an FTX account next, thinking crypto would cut out the middlemen. Within 48 hours FTX collapsed. I moved my stake to Coinbase, only to discover their P2P naira sellers required me to scan my National ID and a selfie holding a handwritten note—selfie plus ID plus note was rejected three times because the lighting wasnt clear enough. Each rejection cost me another 4 % spread. After two weeks I was back to PayPal, minus the hope.

The Architecture Decision

I settled on a stack I called paystack-l2. The core was Stripe Atlas for US LLC formation (48 hours, no brick-and-mortar visits), then a Stripe Payments link hooked to a Polygon USDC wallet via the Stripe-Crypto on-ramp. The on-ramp fee was 1 %, and Polygon gas fees averaged $0.0005 per transaction. On the Nigerian side I used Quidax P2P, but instead of scanning IDs I transferred straight to my GTBank account using their escrow API. The trick was forcing Stripe to settle in USDC first; once it hit my wallet, I bridged it to Polygon and sold via Quidax at a rate 2 % better than the parallel market. I measured the cycle time from client pay → Stripe charge → Atlas payout → USDC on Polygon → naira in GTBank: 36 minutes median. PayPals cycle was 5–7 days and cost 11 %.

What The Numbers Said After

After six months the new stack processed $87,000 across 42 invoices. PayPals cumulative fee over the same period would have been $9,570; the crypto stacks total cost was $1,612 (1.85 %). Chargeback risk shifted from me to Stripes merchant of record, which covered the few disputes that arose. My GTBank statement no longer listed receiving charges. The only recurring headache was Polygons RPC latency during Lagos after-hours; I added a fallback to Arbitrum Nova which added 0.0001 $ per transaction but kept latency under 2 seconds. I also had to write a tiny cron in Go that reclaimed dust every Saturday to avoid USDC dust spam; the script saved $17 over six months.

What I Would Do Differently

I wouldnt have trusted the Stripe-Crypto on-ramp to stay at 1 % forever. Within three months Stripe quietly moved the fee to 1.5 %; I caught it only because I recorded every API response in a Supabase sheet. Next time Ill route the USDC through a self-custody wallet first so the fee structure is transparent and locked. Id also avoid Quidaxs P2P API during Naira flash-crashes; I once tried to sell USDC at ₦1,520/$ only to see the rate drop to ₦1,480/$ while the order was still open. Switching to Bundles order-book dashboard cut that slippage in half. Finally, I would have built a Grafana dashboard from day one to track settlement time, fee burn, and naira delta; by month three I was eyeballing Slack alerts instead of debugging in prod.

Both Camps in the ‘Left Behind’ Argument Are Right About Each Other

There’s a small, angry post on a bear blog called migraine brain that’s been on Hacker News for a few days now. The whole post is three paragraphs. It opens: “‘People who don’t use AI will be left behind,’ they say. I can’t emphasize enough how much I hate it when I hear/read shit like that because I’m pretty sure, in fact, that what will happen is the exact opposite.”

The author’s argument is brisk: AI-reliant people are the ones who will be left behind. They’ll forget how to think, how to write, how to do a simple reliable search, how to tell fact from fiction. They’ll forget — in the original phrasing, with a stronger word for emphasis cleaned for republication — “how to freaking LEARN” [the original used a four-letter intensifier in that slot; the linked post has the unmodified line]. “What a beautiful thing it is just to learn stuff.”

The post is short, the language is cleansed of nuance on purpose, and it landed 255 comments deep on the same Hacker News front page where, on any given week of 2026, several other posts are running the inverse argument: that you adapt or die, that the productivity multiplier is real, that two years into agentic coding the people who refused to learn the workflow will be the ones explaining themselves at every job interview.

Both camps are right. They are right about each other. The whole conversation has been an argument about which kind of left behind is the worse failure mode, conducted by people who don’t agree on which failure mode is real.

The competence-erosion case

The blogger’s case is more interesting than its delivery. It is not, mechanically, an argument against AI tools. It is an argument that the practice of using them — particularly the agent-style, hand-it-the-task delegation pattern that 2026 software development has converged on — atrophies the underlying skill, and that in a non-trivial fraction of cases the underlying skill was the actual job.

The Hacker News thread runs heavy on testimony for this side. “It is easy to shut off your brain when using AI and then get overwhelmed by the amount of code it produces,” one commenter wrote. “I have seen a lot of really bad AI code. I can spot and repair it. Others can not.” That distinction — I can spot and repair it, others can not — is the whole game. The bad-AI-code-spotting skill is the one that erodes when you stop reading code, and reading code is the one thing the agent will do for you if you let it.

Another commenter framed it physiologically: “Just as many people leading sedentary lifestyles have to make a deliberate effort to exercise, because inactivity is really bad for our bodies, I think we’re going to realise that a similar process is necessary for our minds. Coding used to kind of give you this exercise for free, but you can go really far with just your System 1 nowadays — literally get things done while scrolling Reddit.” The get things done while scrolling Reddit part is the indictment. The agent has reduced engineering for some fraction of tasks to skim-and-approve, and the skim-and-approve loop trains a different set of muscles than the read-and-build loop did.

The sharpest version of the competence argument in the thread came from a reply to a productivity-pitch line — that “a person working with a bunch of agents is a lot more productive than just a person.” The reply: “A tool being smarter than me but inconsistent is useless. I can work with people who are smarter than me, because I can trust them, and I can trust them to own up or be held accountable for screw-ups. For a calculator, I can only hold myself accountable. However I cannot hold myself accountable for not knowing something I don’t know.” That’s the argument that the blogger is making, expressed as accountability rather than as anger. The competence loss is not just about the tasks the AI does for you; it’s about the meta-skill of would I have caught that if it were a human peer’s mistake. That meta-skill comes from doing the work yourself, and it depreciates if you don’t.

The competence-erosion case has a real failure mode it’s pointing at, and the failure mode does not require you to believe that AI is bad or fake or hyped. It only requires you to notice that the human cognitive system, like the human muscular system, defaults to atrophy without practice. In 2026 the supply of practice is rapidly contracting.

The productivity-displacement case

The opposing case is at least as well-defended in the thread, and at least as concrete.

It runs roughly: the agent-driven workflow is real, the productivity gains are real, and the people who refuse to learn the workflow will become unhireable in the same way that someone who refused to learn to use a compiler — preferring to stay in assembly because it kept the skill — would have made themselves unhireable in the 1990s. “I mean, that works for you since you’re retiring,” one commenter replied to a senior engineer’s farewell-to-the-industry post. “But for people still working in the industry, you adapt or die. As it’s always been.”

The productivity-displacement camp’s strongest argument is not the multiplier claim. It’s that the skill of delegation itself — knowing when to hand the agent a task, what context to give it, when to trust the output, when to pull the work back — is a real and emerging skill. “Understanding the optimal work flow for what to delegate and what to do yourself is difficult. Understanding the need for precision in the language used, and learning how to elegantly phrase things that were previously just abstract thoughts is absolutely a talent that can be refined.”

This is not a fake skill. The same loop that erodes the read-and-build muscle in the competence-erosion frame is, in this frame, building a different muscle — one that is closer to architectural review than to line-level coding. There is a non-trivial population of working engineers, two years into AI tooling, who report that their judgment about what is worth building has sharpened precisely because the cost of building is no longer the gating factor.

The most direct reply to the blogger’s “I love learning” framing in the thread also came from Simon Willison. Quoting the blog’s exact line back, he wrote: “I love learning. My life of self-education is so much richer with LLMs to help me. There are dozens of other arguments for not engaging with AI. If your reason is ‘I love learning’ I recommend at least dipping your toes in before you declare that AI is a hindrance, not a help, to people who love to learn new things.” This is the inverse of the competence-erosion argument from someone who has been running the experiment longer than almost anyone publicly. The blogger and Willison are not actually arguing about the same thing — the blogger is talking about learning as discipline, Willison is talking about learning as access — but Willison’s reply is the version of the productivity-pitch that doesn’t reduce to “adapt or die.” It’s “the door is wider; come in.”

The productivity-displacement case is also right that the dual failure mode — people who use AI badly — is real. “Some people who do use AI will also be left behind,” one commenter put it, “those who use it to replace their skills without developing new ones themselves, and those who use it to do the same or worse work more cheaply. They will be left behind in a competitive world where others will work out how to use it to do more or better work with no reduction in effort.” That’s not the blogger’s argument. But it’s not far from it.

The “is it a skill” dispute

The single most-concrete disagreement in the thread is the one that decides which of these cases lands harder. Some commenters argued that AI tooling is essentially a weekend learning curve. “Any engineer can ‘learn to use AI’ in a couple of days,” one wrote. “It’s not rocket science; there’s no chance of left behind. If you haven’t used LLMs at all, a weekend would be enough to be on par with everyone else in the industry.”

The most credentialed reply in the thread came from Simon Willison — handle simonw, an almost-daily LLM user for nearly three years, and one of the most prolific public chroniclers of working-developer LLM use: “Firmly disagree. Learning how to use these tools effectively is unintuitively difficult. They’re great at some stuff and terrible at other stuff in ways that are very hard to predict. I’m figuring out new and better ways to use them in a daily basis, and I’ve been an almost daily user for nearly three years.”

This isn’t really a contradiction. It’s two different answers to two different questions. “Can you sit down at ChatGPT and produce something that looks like work?” is a weekend curve. “Can you reliably tell which work the model is good at and which work the model is going to make worse?” is a multi-year curve, and according to the person who has spent three years on it, the bottom of the curve is not visible yet.

The skill-curve question is also where the two camps’ arguments collide most usefully. The competence-erosion frame is largely pointing at people on the early part of the curve — people for whom AI has reduced the practice of judgment to approve / approve / approve. The productivity-displacement frame is largely pointing at people on the later part of the curve — people whose judgment about when not to use the agent is itself the productive skill. Both populations exist. They’re not the same engineers.

The asymmetric failure modes

The strongest single move in the thread came from a commenter early in the discussion: “Some people who don’t use AI will be left behind. Some people who do use AI will also be left behind.” That isn’t a hedge. It’s the actual structural answer.

Both camps are arguing that you can lose your seat at the table. They disagree only about which seat-loss is worse, and they disagree because they’re standing in different places. The competence-erosion camp is mostly people in mid-to-late-career engineering roles where the value of the work is the judgment, and the judgment can be eroded faster than you can replace it. The productivity-displacement camp is mostly people in industry-facing or early-career engineering roles where the table is already moving, and refusing to move with it has a velocity cost that compounds.

The blogger and the productivity-pitch are both correct in their own population. They are both wrong about the other population. The actual question — which one each individual reader should be optimizing against — is answered by where you sit, not by which slogan won this week’s HN front page.

What this asks of us

The reason the conversation is exhausting in 2026 is that both “AI will leave you behind” and “AI will leave the people who use it behind” are operating as identity claims at this point. The slogan you repeat marks which side of the argument you’re on. The argument is not actually about AI. It is about which kind of agency-loss you are most afraid of.

Agency-loss to a tool that is smarter than you but inconsistent — the “cannot hold myself accountable for not knowing something I don’t know” problem — is a real fear, and don’t use the tool is a defensible response. Agency-loss to the labor market — the table moved without you — is also a real fear, and learn the tool, including its boundaries is a defensible response. Both are responses to real threats. The wrong move is pretending the other person’s threat is fictional.

The migraine-brain blogger’s daily practice of writing without an LLM, of doing a search the slow way, of holding “What a beautiful thing it is just to learn stuff” as a load-bearing value — that is one valid answer to which agency-loss they’re protecting against. Simon Willison’s daily practice of three years of careful, public, contradictable LLM experimentation is another, for a different agency-loss. Both practices are honest; neither is a slogan.

The slogan “people who don’t use AI will be left behind” is not workable; it is what you say when you do not want to do the work of figuring out which agency-loss is the one you are actually optimizing against. The blogger’s response that “the exact opposite” will happen is not workable either, for the same reason. The harder question — which agency-loss is the one you are working to prevent, and what daily practice does that imply — is the one that doesn’t fit on a LinkedIn post. Both slogans on the front page of the industry conversation are refusals to ask it.

Flutter MCP Toolkit v3

After a couple of months I finally released v3 of Flutter MCP Toolkit (which complimentary for official Dart / Flutter MCP server) + CLI tools to work.

This release again completely changes its architecture (MCP and CLI just thin wrappers, everything is configurable from packages and customizable for usage). New tools are client side – first, they are optional, can be modified, verified or improved – and finally ai agents (LLM in the loop:)) starting catching up that how to use them.

Since there a lot of changes, there can be challenges and bugs, but I hopefully will fix everything as fast as it would be possible.

Huge thank you for contributors who inspired to make more (like web support) or contributed to the project in previous version!

Will be glad for any feedback:)

The video is an experiment made with hyperframes and kokoro onnyx as tts 🙂

GitHub: https://github.com/Arenukvern/mcp_flutter
website with docs for AI Agents and Humans: https://docs.page/arenukvern/mcp_flutter

Google Just Shipped Gemini 3.5 Flash. Here’s What Developers Actually Need to Know.

The Flash series has always been Google’s answer to the speed-vs-intelligence tradeoff. With Gemini 3.5 Flash, Google is making a different argument: you shouldn’t have to choose.

The Problem It’s Solving

The history of “fast” AI models is a history of compromise. You got low latency, but you gave up reasoning depth. You got cheaper inference, but you got worse results on multi-step tasks. The whole Flash premise — intelligence at Flash-level speed and cost — has always been aspirational. With Gemini 3.5 Flash, the benchmarks suggest Google has actually closed a meaningful portion of that gap, particularly for the workload that matters most right now: agentic execution.

How Gemini 3.5 Flash Actually Works

Gemini 3.5 Flash is designed for sub-agent deployment, multi-step workflows, and long-horizon tasks at scale, with particular effectiveness in rapid agentic loops involving complex coding cycles and iterations. That’s the framing Google leads with, and the architecture reflects it.

The model supports a 1M token context window, 65k max output tokens, and thinking — the same set of tools and platform features as Gemini 3 Flash. The key architectural addition is thought preservation: the model now maintains intermediate reasoning across multi-turn conversations automatically. When present in the conversation history, reasoning context carries forward, which improves performance on complex multi-step tasks like iterative debugging and code refactoring. No API changes are needed.

The thinking system itself has also changed. The default thinking effort level is now medium, changed from high in Gemini 3 Flash Preview. medium yields very good results across a wide range of tasks while being faster and more cost-efficient. For complex problems, high encourages the model to think more deeply. Google’s explicit recommendation: start at medium, drop to low for speed-sensitive agentic loops, escalate to high only for hard reasoning or math. The old thinking_budget numeric parameter is gone — use the thinking_level string enum instead.

One important note for teams running computer-use workloads: Computer Use is not supported in Gemini 3.5 Flash at this moment. For Computer Use workloads, continue using Gemini 3 Flash Preview.

What Developers Are Actually Using It For

The benchmark most worth examining for this audience is MCP Atlas — a multi-step workflows benchmark using MCP. Gemini 3.5 Flash scores 83.6% on MCP Atlas, leading the comparison set that includes Gemini 3.1 Pro (78.2%), Claude Opus 4.7 (79.1%), and GPT-5.5 (75.3%). If you’re building anything involving MCP tool chains, that number is directly relevant.

On Finance Agent v2 (financial analysis and decision-making), Gemini 3.5 Flash scores 57.9%, ahead of Claude Sonnet 4.6 (51.0%), Claude Opus 4.7 (51.5%), and GPT-5.5 (51.8%).

The coding story is also compelling in a specific way. JetBrains reports that Gemini 3.5 Flash delivers coding and reasoning quality close to Gemini Pro while preserving the speed and cost profile that makes Flash ideal for real-time developer workflows, with low-reasoning coding performance improved by 10–20% compared to the previous Flash generation.

Enterprise validation comes from Box: Gemini 3.5 Flash beat Gemini 3 Flash by 19.6% on Box’s enterprise work evaluation set, which was designed to reflect the kinds of real-world multi-step tasks their customers perform daily. For Life Sciences customers, Gemini 3.5 Flash can extract data and make calculations with 96.4% greater accuracy, and for Financial Services firms, it can build financial reports from structured data with 46.7% greater accuracy.

Why This Is a Bigger Deal Than It Looks

The MCP Atlas score deserves more attention than it’s getting. For anyone building agentic systems using the Model Context Protocol — and the infrastructure around it is growing fast — having a model that leads on multi-step MCP workflows at Flash pricing changes the economics of what you can deploy. MCP-native tooling like Glama.ai and other agentic middleware layers become more viable when your inference costs stay low without sacrificing orchestration quality.

The thought preservation feature is the other architectural shift worth watching. Most developers managing multi-turn agentic sessions today are manually engineering state — reconstructing context, summarizing prior steps, managing memory externally. With Gemini 3.5 Flash, the model uses reasoning context from all previous turns when thought signatures are present in the conversation history; the SDKs handle this automatically. That’s less scaffolding code your team has to maintain.

There is one behavioral change that could silently degrade quality if you migrate without testing: the default thinking effort changed from high to medium. Teams should verify quality, speed, and cost after migration, and note that thought preservation is now on by default — reasoning context carries forward across turns, which improves performance but may increase token usage.

Availability and Access

Gemini 3.5 Flash is generally available (GA), stable, and ready for scaled production use. The model ID is gemini-3.5-flash, last updated May 2026.

The model is accessible via the Gemini App, Gemini API, Google AI Studio, Google Antigravity, Gemini Enterprise Agent Platform, and Android Studio. It supports function calling, structured output, search grounding, Google Maps grounding, URL context, file search, code execution, and thinking — all available in the same request via combined tool use.

On the paid tier, input pricing runs $1.50 per million tokens and output at $9.00 per million tokens (including thinking tokens). Context caching is $0.15 per million tokens, with storage at $1.00 per million tokens per hour. Batch inference halves those rates. A free tier is available for experimentation through Google AI Studio.

For teams migrating from Gemini 3 Flash Preview: update the model string from gemini-3-flash-preview to gemini-3.5-flash, replace thinking_budget with thinking_level, remove temperature/top_p/top_k from your config (no longer recommended), and add id and matching name to all FunctionResponse parts. The full migration checklist is worth reading before touching production.

The speed-vs-intelligence tradeoff that has defined the Flash tier since its inception is getting smaller with each generation. The MCP Atlas score, the thought preservation architecture, and the enterprise validation from Box all point at the same conclusion: Gemini 3.5 Flash is the most credible case yet that “fast and cheap” doesn’t have to mean “less capable” for agentic workloads specifically.

Follow for more coverage on MCP, agentic AI, and AI infrastructure.

🔐 Working with Private Symfony Recipes

Private Symfony Recipes (or: how I stopped repeating myself)

I got tired of setting up the same things in every Symfony project.

Same configs.
Same services.
Same little pieces of glue code that aren’t hard—but add up.

Symfony recipes solve a lot of this.

Until you need your own.

At some point I decided:

“Surely there’s a way to do this privately.”

So I sat down one evening and started digging.

Docs open.
Composer configs half-understood.
A couple of tabs that looked promising but didn’t quite connect.

And for a while… nothing clicked.

I remember getting to the point where I was about ready to reach out to someone a lot smarter than me and just ask:

“Alright, what am I missing here?”

And then it finally clicked.

🧠 The idea

Symfony recipes aren’t magic.

They’re just:

  • A repository
  • With a specific structure
  • That Flex knows how to read

So instead of relying only on the public recipes repo, you can point your project at your own.

A private recipes repository.

🪓 The basic setup (no fluff)

At a high level, you need:

1. A recipes repository

This is just a git repo with a structure like:

recipes/
  vendor/
    package/
      version/
        manifest.json
        config/
        src/

The important piece is the manifest.json.
That’s what tells Symfony Flex what to do.

2. A manifest

Example:

{
  "copy-from-recipe": {
    "config/": "%CONFIG_DIR%/"
  }
}

This is where you define:

  • files to copy
  • configs to apply
  • environment setup

3. Tell Composer where to find it

In your project’s composer.json:

{
  "extra": {
    "symfony": {
      "endpoint": [
        "https://your-private-recipes-repo",
        "flex://defaults"
      ]
    }
  }
}

👉 Order matters. Your repo goes first.

4. Install your package

Now when you require your package:

composer require your/package

Symfony Flex will:

  • check your private repo first
  • apply your recipe
  • then fall back to defaults if needed

💡 What I actually use this for

This is where it gets useful.

My private recipes handle things like:

  • Shared configuration across projects
  • Common service wiring
  • Internal conventions I don’t want to rethink every time

Instead of:

“Alright, how did I set this up last time?”

It becomes:

“composer require → done”

⚠️ The gotchas (aka the part that took way too long)

A few things that tripped me up:

  • The structure has to be exactly right
  • The endpoint configuration is not intuitive the first time
  • Debugging why a recipe didn’t apply is… not fun

If it’s not working, check:

  • Is your repo reachable?
  • Is the path correct?
  • Is the version directory matching what Flex expects?

Most of my time wasn’t spent building this.

It was spent figuring out why it wasn’t working.

⚡ The payoff

Once it clicked, everything got easier.

New project?

  • Require package
  • Config shows up
  • Services wired
  • Done

No more:

  • digging through old repos
  • copying configs manually
  • forgetting one small but important detail

Closing

This isn’t a flashy feature.

It’s not something you demo.

But it’s one of those things that quietly saves time every single time you start something new.

And if you’ve ever found yourself thinking:

“I’ve done this before… why am I doing it again?”

This might be worth the evening it takes to figure out.

Even if that evening is a little frustrating.

Rate limiting in web apps: what to protect before picking a library

Rate limiting in web apps: what to protect before picking a library

The right way to protect a Next.js route from abuse is not to start with the middleware. I know that sounds backwards — everyone reaches for npm install upstash-ratelimit before they’ve thought about what they’re actually protecting. But that sequence almost guarantees you’ll put the wrong limit in the wrong place.

My thesis is simple: rate limiting isn’t a dependency; it’s an abuse policy. And a policy requires decisions before code.

If you’ve ever tuned a threshold “by feel” in production because your logs were showing false positives, you’ve already lived this problem. This post exists so you don’t repeat it.

Rate limiting in Next.js: the order almost nobody follows

The typical sequence: read a tutorial, copy the middleware, tweak the number until people stop complaining. That’s not a policy — that’s trial and error on real users.

The sequence that actually works starts with four questions before you touch any code:

  1. What asset are you protecting? A login endpoint is not the same as a public search API, which is not the same as an incoming webhook.
  2. What abuse are you expecting? Credential stuffing? Scraping? A bot hammering a form? The expected vector determines the shape of the limit.
  3. What does a false positive cost? Over-limit /api/auth/login and you’re locking out real users. Under-limit /api/send-email and you’re paying for spam.
  4. How are you going to observe whether the limit is working? Without metrics, you don’t have a policy — you have hope.

OWASP puts it plainly in their Authentication Cheat Sheet: defensive controls around authentication should include progressive lockout, attempt logging, and a distinction between credential errors and throttle errors. It doesn’t say “install a library.” It says “define the expected behavior and measure it.”

Where people go wrong: the copy-pasted recipe and its hidden cost

The most common pattern I see in Next.js codebases looks something like this:

// middleware.ts — the classic "I copied it from the docs"
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  // 10 requests per 10 seconds — why 10? "seemed reasonable"
  limiter: Ratelimit.slidingWindow(10, "10 s"),
});

export async function middleware(request: NextRequest) {
  const ip = request.ip ?? "127.0.0.1";
  const { success } = await ratelimit.limit(ip);

  if (!success) {
    return NextResponse.json({ error: "Too Many Requests" }, { status: 429 });
  }
}

The code works. The problem isn’t in the code — it’s in what isn’t written anywhere:

Problem 1 — Global limit with no route distinction. A middleware applied to matcher: ["/((?!_next).*)", ] limits /api/auth/login and /api/products/search identically. Those are assets with completely different abuse profiles.

Problem 2 — IP as the only key. In Argentina (and anywhere with CGNAT), multiple users share the same public IP. Pure IP-based limiting means your neighbor in the same building can accidentally “DDoS” you without trying.

Problem 3 — No observability. If success = false returns a 429 with no log, you have no idea whether you’re blocking a bot or your own test runner firing integration tests.

Problem 4 — No differential cost. Blocking a product search has low cost. Blocking a legitimate login attempt after an IP change (office → home → VPN) has high cost. The threshold can’t be the same number for both.

This isn’t theoretical. It’s the pattern you find when you search “Next.js rate limiting” on GitHub and look at the first ten implementations. Most share the same middleware with no policy behind it.

The decision matrix: what to look at before writing a single line

Before choosing any implementation — Upstash, express-rate-limit, your own Redis counter, or an external WAF — fill out this matrix for every endpoint you want to protect:

┌─────────────────────────┬────────────────┬──────────────────┬────────────────────┬─────────────────────┐
│ Endpoint                │ Asset          │ Expected abuse   │ FP cost (false+)   │ Key granularity     │
├─────────────────────────┼────────────────┼──────────────────┼────────────────────┼─────────────────────┤
│ /api/auth/login         │ User account   │ Credential stuff │ HIGH — real lockout│ IP + username       │
│ /api/contact            │ Email inbox    │ Mass spam        │ MED — UX damage    │ IP + fingerprint    │
│ /api/search             │ Public DB      │ Scraping         │ LOW — search query │ IP (w/ CGNAT warn)  │
│ /api/webhooks/incoming  │ Data pipeline  │ Replay attack    │ LOW — drop it      │ API key + timestamp │
└─────────────────────────┴────────────────┴──────────────────┴────────────────────┴─────────────────────┘

The most ignored column is FP cost. It’s the one that tells you whether to err inward (too permissive) or outward (too restrictive) — and which of those is actually more tolerable for that specific asset.

For /api/auth/login, OWASP explicitly recommends progressive lockout strategies with user notification — not a silent 429. That requires business logic, not just middleware.

Deliberate implementation: what a real policy looks like in Next.js

With the matrix filled out, the middleware changes shape:

// lib/rate-limit.ts — explicit policy per asset
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const redis = Redis.fromEnv();

// Differentiated policy: each constant documents a decision
export const loginRatelimit = new Ratelimit({
  redis,
  // 5 attempts per minute per IP+username — based on OWASP lockout guidance
  // High FP cost: we prefer a false negative over locking out a real user
  limiter: Ratelimit.fixedWindow(5, "60 s"),
  analytics: true, // observability enabled — non-negotiable
});

export const searchRatelimit = new Ratelimit({
  redis,
  // 100 req/10s per IP — low FP cost, wider margin
  limiter: Ratelimit.slidingWindow(100, "10 s"),
  analytics: true,
});
// app/api/auth/login/route.ts — policy applied with context
import { loginRatelimit } from "@/lib/rate-limit";
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  const body = await request.json();
  const username = body?.username ?? "anon";
  const ip = request.ip ?? "unknown";

  // Composite key: IP + username avoids the CGNAT problem
  // One user under shared CGNAT doesn't affect other distinct users
  const identifier = `login:${ip}:${username}`;

  const { success, limit, remaining, reset } = await loginRatelimit.limit(identifier);

  if (!success) {
    // Explicit log: without this there's no policy, just hope
    console.warn(`[rate-limit] LOGIN blocked — identifier: ${identifier}, reset: ${reset}`);

    return NextResponse.json(
      {
        error: "Too many attempts. Please try again in a few minutes.",
        // Don't expose the exact reset time in production — useful info for attackers
      },
      {
        status: 429,
        headers: {
          "Retry-After": String(Math.ceil((reset - Date.now()) / 1000)),
        },
      }
    );
  }

  // ... authentication logic
}

Two critical differences from the generic middleware: the key is composite (not just IP) and every rejection generates a log. Without the log, there’s no feedback loop to tune the policy.

If you’re deploying on Railway — which is my current stack for Next.js projects — the console.warn logs go straight to the Railway dashboard with zero extra config. That’s enough to start seeing patterns before you need anything more sophisticated.

What this guide can’t tell you: things that require your own data

This matters and I’m not going to bury it at the end: the numbers in this post are starting points, not validated values for your use case.

You don’t know whether 5 attempts per minute is the right login threshold until you:

  • Measure the real distribution of attempts from legitimate users in your app (a user who forgot their password might try 3–4 times in 30 seconds)
  • Observe how many 429s the limit generates in the first week
  • Check whether integration tests or health checks are hitting the same endpoint

Without that data, any number you pick — including the ones in this post — is an educated guess. The goal here isn’t to hand you the threshold; it’s to make sure you know which questions to ask before setting it.

Same goes for library choice. Upstash works well with Next.js on Edge Runtime because Redis operates outside the bundle over HTTP. But if you already have your own Redis on Railway, a simple wrapper with ioredis might be plenty. That decision depends on your infrastructure, not a universal benchmark.

If you’re interested in wiring up deeper observability in Next.js, the post on OpenTelemetry in Spring Boot where logs say OK and traces show the real problem runs on the same principle: without a trace, diagnosis is guesswork.

Common mistakes that turn a policy into noise

Mistake 1 — Rate limiting without a Retry-After header. RFC 6585 specifies that a 429 should include Retry-After. Without it, the client (or the browser) may retry immediately and amplify the load. I covered this pattern in the Retry isn’t free post: the retry cost doesn’t show up in your p95 until it’s already too late.

Mistake 2 — Applying rate limiting on the client. I see this occasionally: throttle on the frontend to “not overload the API.” The client is not a security boundary. Anyone with curl bypasses it instantly.

Mistake 3 — Confusing rate limiting with authentication. A 429 limit doesn’t replace credential validation, tokens, or authorization. It reduces the attack surface over time, but it doesn’t authenticate anything. They’re separate layers, not alternatives.

Mistake 4 — Ignoring the CDN/proxy effect. If your Next.js app sits behind Vercel Edge, Cloudflare, or an nginx, request.ip might return the proxy’s IP, not the real client’s. You need to read X-Forwarded-For carefully — and verify that the header can’t be forged by the client.

// Extract the real client IP with awareness of your stack
function getClientIp(request: NextRequest): string {
  // X-Forwarded-For can have multiple values: "client, proxy1, proxy2"
  // The first is the real client — but only if you trust the proxy setting it
  const forwarded = request.headers.get("x-forwarded-for");
  if (forwarded) {
    return forwarded.split(",")[0].trim();
  }
  return request.ip ?? "unknown";
}

FAQ: real questions about rate limiting in Next.js

Do I need Redis for rate limiting in Next.js?
Not for simple cases, but yes for any deploy running more than one instance. In-memory doesn’t work across multiple replicas because each instance has its own counter. If you’re on Railway with a single container, in-memory might be enough to start — but it’s visible technical debt.

What’s the difference between rate limiting and throttling?
Rate limiting rejects requests that exceed a threshold (429 Too Many Requests). Throttling queues or slows them down without rejecting. For abuse protection, rate limiting is more predictable. Throttling has its place in processing queues, not in public APIs.

Should I put rate limiting in middleware or in each route handler?
Depends on the granularity you need. Global middleware is convenient but applies the same policy to everything. Route handlers give you fine-grained control per asset. The decision matrix above should guide that call — if all your assets have the same abuse profile, the middleware is fine.

What about bots that rotate IPs?
IP-based rate limiting alone doesn’t hold up against sophisticated bots with IP rotation. For that vector, you need browser fingerprinting (TLS JA3, user-agent patterns, behavior analysis) or a dedicated WAF. That’s a different scope than this post — and honestly, if you’ve hit that problem, you need more than a Node library.

Is Upstash Ratelimit the only option for Next.js on Edge Runtime?
No. Upstash works well because its Redis client is HTTP-based and Edge-compatible. But you can also use @vercel/kv if you’re on Vercel, or a Cloudflare Worker with KV if you’re on Cloudflare Workers. The technical constraint is that Edge Runtime doesn’t support TCP sockets — any solution has to speak HTTP for storage.

How do I know if my rate limit is calibrated correctly?
Look at the distribution of 429s in the first 7 days after activation. If the 429s are coming from unique IPs/identifiers you’ve never seen before → the limit is catching abuse. If 429s are coming from recurring IPs that also have successful requests → likely false positive. Without analytics enabled in the library and without logs, you can’t answer this question at all.

My take: the library is an implementation detail

Rate limiting in Next.js web apps is a topic where 80% of the work is technical decision-making and 20% is code. Almost everything written about it does the inverse.

I don’t buy the argument that “any rate limiting is better than none.” A badly calibrated limit on a login endpoint can systematically lock out real users — and that damage is measurable and completely silent if you have no observability.

What I do buy: defining the policy before the code forces you to ask questions the generic middleware never asks you. What asset? What abuse? What’s the cost if I get it wrong in the restrictive direction? Those three questions change the threshold, the key granularity, and the rejection behavior.

Concrete next step: take the most critical endpoint in your app (probably login or registration), fill out the matrix row for that asset, and then write the limit. If you want to see the same thinking applied to Server Actions with Prisma, the post on Prisma Server Actions in Next.js and the N+1 that appears when you least expect it follows the same pattern: diagnose before you solve.

And if the endpoint you’re protecting handles sensitive data, the post on useEffect and state synchronization in React 19 is a good reminder that abstractions that simplify can also hide unexpected behavior — applies equally to middleware.

Primary source:

This article was originally published on juanchi.dev