Structured Outputs Are the Contract Your AI Agent Is Missing

Free-text agent responses look great in demos. They fall apart the moment you try to automate anything downstream. Here’s the pattern that fixes it.

Your AI agent just summarized a payer’s response to a benefits verification request. The output reads beautifully:

“The patient’s coverage appears to be active under the Gold Plus plan. Prior authorization is likely required based on the payer’s language, though this isn’t entirely clear. The copay seems to be around $50-75, and there may be a quantity limit of 30 tablets per month.”

Now try writing code that processes that. Does “appears to be active” mean coverage is confirmed? Is prior auth required or not? What’s the copay — $50 or $75? And good luck extracting “30 tablets per month” reliably when the next response says “one month supply” or “qty limit: 30 units.”

This is the problem that structured outputs solve. And once I started treating them as the contract between the LLM and the rest of my system, every other design decision got easier.

The Problem: Beautiful Text, Useless Automation

I’ve built AI agents for regulated workflows in Life Sciences — benefits verification, clinical trial site selection, medical inquiry response. In every case, the agent’s output needed to drive downstream automation: update a record’s status, create follow-up tasks, route exceptions to the right person, trigger the next step in a workflow.

My first implementations all made the same mistake. I let the LLM return natural language summaries and then tried to parse them. Sometimes I wrote regex. Sometimes I added a second LLM call to “extract the fields.” Sometimes I just gave up and had a human re-read the summary and fill in the fields manually.

Every approach was fragile because I was depending on the LLM being consistent in how it phrases things. And consistency is exactly what LLMs don’t guarantee.

The Fix: Define a Schema, Enforce It in the Prompt

A structured output is a JSON object with a defined schema that the LLM must conform to. Instead of asking “summarize this payer response,” you ask “extract the following fields from this payer response and return them as JSON.”

Here’s what the output contract looks like for a benefits verification summary:

{
  "coverageConfirmed": true,
  "priorAuthRequired": false,
  "copayNotes": "$50 copay per fill",
  "deductibleNotes": "$500 annual, not yet met",
  "limitationsNotes": "Specialty pharmacy required",
  "missingInfo": ["Effective date not stated"],
  "confidence": 82
}

Every field has a type. Every field has a purpose. And the downstream logic is trivial:

// This is the entire routing logic. No parsing. No interpretation.
if (summary.missingInfo.length === 0 && summary.confidence >= 75) {
  status = "Verified";
} else {
  status = "Needs Follow-up";
  for (const item of summary.missingInfo) {
    createTask(`Follow up: ${item}`, recordId);
  }
}

Compare that to trying to extract the same decisions from a paragraph of prose. The structured output turned a fragile NLP problem into a simple conditional.

How to Get the LLM to Comply

The schema alone isn’t enough — you need to tell the LLM exactly what you expect. The prompt template has three parts:

1. Role and constraints:

You are a benefits verification analyst. Extract structured data
from the payer response below. Never invent information that isn't
in the response. If a field cannot be determined, use null for
booleans, "Not stated" for strings, and add the missing item to
the missingInfo array.

2. Context (merge fields from your system):

Patient: Jane Doe
Plan: AcmeHealth Gold Plus
Drug: RX-OMNI 10mg
Payer response: [raw text pasted or ingested here]

3. Output schema with field descriptions:

Return ONLY a JSON object matching this schema:
{
  "coverageConfirmed": boolean — is coverage active for this drug/plan?,
  "priorAuthRequired": boolean — does the payer require prior auth?,
  "copayNotes": string — copay amount and terms, or "Not stated",
  "deductibleNotes": string — deductible details, or "Not stated",
  "limitationsNotes": string — quantity limits, step therapy, etc.,
  "missingInfo": [string] — list of fields that couldn't be determined,
  "confidence": number 0-100 — how complete/clear was the payer response?
}

The field descriptions inside the schema are doing heavy lifting. They tell the LLM what each field means, what the valid values are, and what to do when information is missing. Without them, you get inconsistent interpretations — one run puts copay info in limitationsNotes, the next puts it in copayNotes.

If you’re using a platform that supports structured output enforcement natively (OpenAI’s response_format, Salesforce’s Prompt Builder structured outputs), use it. If not, the prompt-based approach works well as long as you validate the JSON before processing it.

Three Domains, Same Pattern

The schema changes, but the pattern is identical across every use case I’ve built.

Clinical trial site feasibility:

{
  "enrollmentCapacity": "15-20 patients/year",
  "therapeuticExperience": "3 prior Phase III oncology trials",
  "regulatoryReadiness": "IRB approved, ethics committee pending",
  "riskFlags": ["Ethics committee approval pending"],
  "overallScore": 72,
  "recommendation": "Conditionally activate"
}

The riskFlags array drives automatic task creation — one task per flag. The overallScore drives routing: 80+ with no flags goes to “Activation Ready,” 60-79 goes to “Under Review,” below 60 goes to “On Hold.” No human reads a paragraph and decides what to do. The schema decides.

Medical inquiry response:

{
  "answer": "Based on the Phase III LIBERTY trial...",
  "confidence": "high",
  "sourceDocs": ["DOC-2024-001", "DOC-2024-047"],
  "escalateFlag": false,
  "nextBestAction": "send_response"
}

The nextBestAction field is the routing mechanism. If the confidence is low or escalateFlag is true, the workflow opens a collaborative review instead of queuing the response for send. The sourceDocs array makes the response auditable — a reviewer can verify that the answer is grounded in approved content.

The Audit Advantage

Structured outputs give you something free-text summaries never can: a complete, machine-readable audit trail.

Every time my agent runs, I store three things on the record:

AI_Summary_JSON__c  = raw JSON output (the full structured response)
AI_Last_Run_By__c   = the user who triggered the agent
AI_Last_Run_At__c   = timestamp

Six months from now, when compliance asks “why was this case marked as Verified?”, I can show them the exact JSON the agent produced, with the exact confidence score and the exact fields it extracted. I can show who triggered it and when. Try doing that with a paragraph of prose that was copy-pasted into a notes field.

In regulated industries — Life Sciences, healthcare, financial services — this isn’t a nice-to-have. It’s the difference between “we can demonstrate what happened” and “we think the AI said it was fine.”

When NOT to Use Structured Outputs

Structured outputs are the right pattern when the agent’s output drives automation. They’re the wrong pattern when:

  • The user is having a conversation and wants a natural language response
  • The task is creative or exploratory (brainstorming, writing, ideation)
  • The output is the final product, not an intermediate step in a workflow

If a human is the consumer of the output, natural language is fine. If a system is the consumer, demand a schema.

Start With the Schema

If I could go back and give myself one piece of advice before building my first AI agent, it would be this: design the output schema before you write a single prompt.

The schema forces you to answer the hard questions early. What fields does the downstream system need? What are the valid values? What happens when information is missing? What score threshold triggers which action?

Every prompt you write, every test you create, every piece of routing logic you build — all of it flows from the schema. It’s the contract between the AI and the rest of your system. Get it right first, and everything downstream gets simpler.

Get it wrong — or skip it and use free text — and you’ll spend the next six months writing parsers, debugging inconsistent extractions, and explaining to stakeholders why the agent “sometimes gets it right.”

Define the contract. Enforce it. Version it. Your future self will thank you.

AI Won’t Replace Good Testers — But It Will Expose Weak Testing Faster

AI Testing

What software quality looks like when systems are complex, domain-heavy, and too important to guess

There is a dangerous illusion growing in software teams right now:
“If we use AI in testing, quality will automatically improve.”
It won’t.
AI can speed things up.
It can help testers think faster.
It can generate ideas, test scenarios, edge cases, and even documentation.
But in complex industrial software, especially in sectors like energy, engineering, simulation, operations, and decision-support systems, AI can also make one problem worse:
It can help teams become confidently wrong, faster.
And that is far more dangerous than simply being slow.
This article is not about hype.
It is about what I believe is the real question for testers in the AI era:
Will AI improve testing — or will it expose how shallow our testing already was?

The problem is not AI. The problem is shallow validation.

In many teams, software quality is still judged by a comforting set of signals:
• tests are passing
• automation is green
• pipelines are clean
• no critical crashes were reported
And when those signals look healthy, people relax.

But in complex systems, especially domain-heavy systems, that confidence can be misleading. Because there is a big difference between:
• the software behaving consistently
and
• the software behaving correctly

That difference matters enormously in applications where software influences:
• engineering decisions
• operational planning
• production workflows
• calculations used by experts
• risk-based or cost-based choices
In those environments, the biggest failures are not always dramatic.
Sometimes the most dangerous issue is this:
The software returns an answer that looks valid, but should never have been trusted.
That is exactly where AI becomes interesting — and risky.

AI is very good at helping you test the obvious

AI is already useful in software testing.
Used well, it can significantly improve productivity.
A tester can use AI to help with:
• generating test ideas
• identifying missing edge cases
• summarizing requirements
• rewriting vague bug descriptions
• creating structured exploratory testing charters
• improving traceability between scenarios and expected behavior
• translating complex logic into clearer test conditions

That is valuable.
In fact, for manual testers working in large or domain-heavy systems, AI can act like a thinking accelerator. But here is the catch:
AI usually works best on what is already visible, documented, or inferable.
That means it is very good at helping you test:
• what is written
• what is expected
• what appears structurally logical

What it is not naturally good at is detecting when:
• the requirement is incomplete
• the business rule is subtly wrong
• the engineering assumption is unrealistic
• the output is technically valid but operationally misleading
• the software is behaving “correctly” according to logic, but incorrectly according to reality

And that is exactly why good testers still matter more than ever.

In industrial software, “correct” is often not enough

One of the biggest misunderstandings in testing is the assumption that correctness is purely technical.
In simple apps, that may be enough. But in complex industrial systems, quality often depends on a deeper question:
Does this result make sense in the real world this software is meant to support?

That is a very different kind of validation.
For example, imagine a system in the energy industry that supports:
• calculations
• engineering workflows
• scenario comparisons
• planning logic
• operational or subsurface interpretation
• data-driven recommendations

The software may:
• load correctly
• calculate correctly
• return stable outputs
• pass all regression checks

And still be wrong in a way that matters.
Not because of a crash.
Not because of a syntax error.
Not because a test case failed.

But because the software may quietly allow:
• unrealistic assumptions
• misleading defaults
• dangerous simplifications
• domain-invalid interpretations
• plausible but poor-quality outputs

These are the kinds of issues that often survive both automation and shallow manual testing.
Why?
Because they don’t look broken.
They look reasonable.
And that is exactly what makes them dangerous.

AI can help you test faster — but it cannot replace domain judgment

This is where I think the conversation around AI in QA often becomes too simplistic.
People ask:
“Can AI write test cases?”
“Can AI help automate test design?”
“Can AI improve test productivity?”

Yes.
Absolutely.
But those are not the most important questions.
The more important question is:
Can AI tell when the software is making a decision that should not be trusted?
And in most real-world industrial environments, the answer is:
Not reliably without human domain understanding.

Because AI does not automatically know:
• what engineers actually care about
• what users will assume from a result
• what values are realistic in practice
• what shortcuts are acceptable
• what “looks fine” but would mislead a real expert
That judgment still belongs to testers, analysts, and domain-aware quality professionals.
This is why I believe the strongest testers in the next few years will not simply be the people who use AI.
They will be the people who know when not to trust it blindly.

The real opportunity: use AI as a quality amplifier, not a quality substitute

This is the mindset shift that matters most.
AI should not be treated as a replacement for testing judgment.
It should be treated as a quality amplifier.

That means using it to improve:
• speed
• clarity
• breadth of thinking
• scenario generation
• requirement interpretation
• exploratory preparation

But not using it as a substitute for:
• domain reasoning
• engineering context
• risk assessment
• product intuition
• critical skepticism

When used properly, AI can make a good tester significantly stronger.
It can help uncover:
• missing assumptions
• scenario blind spots
• incomplete validation paths
• overlooked combinations
• weakly defined expected behavior

But it only becomes powerful when paired with a tester who knows how to ask:
• What is this software really trying to support?
• What would a real user assume from this result?
• What could silently go wrong here?
• What would be expensive, dangerous, or misleading if this were wrong?

That is not just testing.
That is quality thinking.
And quality thinking is still deeply human.

Weak testing becomes more obvious in the AI era

This is the uncomfortable truth, AI will not just improve testing, it will also expose poor testing practices faster.

Teams that already rely too heavily on:
• happy path validation
• shallow requirement interpretation
• checkbox testing
• “green means good” thinking
will likely use AI to produce more output without producing more insight.

That means they may end up with:
• more test cases
• more generated scenarios
• more structured documentation
…but still miss the same critical problems.

Because the real weakness was never the lack of test volume.
The weakness was the lack of deep understanding.
And AI cannot fix that for you, it can only reveal it.

What better testing looks like in an AI-assisted future

In my view, better testing in complex software environments will increasingly require a combination of four things:

1. Technical awareness
You need to understand:
• system behavior
• interfaces
• workflows
• failure patterns
• data dependencies

2. Domain understanding
You need to understand:
• what the software is actually for
• what “good output” looks like
• what users trust
• what outcomes would be misleading or dangerous

3. AI fluency
You need to know how to use AI to:
• accelerate thinking
• challenge assumptions
• improve test preparation
• uncover blind spots faster

4. Critical judgment
You need the discipline to ask:
“Just because this looks valid… should we trust it?”
That one question may become one of the most valuable QA skills in the AI era.

Final thought

AI will absolutely change software testing.
It already is.
But the teams that benefit the most will not be the ones that use AI the fastest, they will be the ones that use it wisely, especially in systems where software quality cannot be reduced to “test passed.”
Because in complex software, and especially in industrial or energy-related systems, the biggest quality failures are often not obvious.
They are subtle.
Quiet.
Plausible.
And expensive.

And those are still the kinds of problems that require a tester who understands more than tools.
So no, I do not think AI will replace good testers.
But I do think it will expose weak testing faster than ever before.
And that may be one of the best things to happen to quality.

What about you?

Have you used AI in testing in a way that actually improved quality, not just speed?
Or have you seen situations where software passed every check, but still didn’t make sense in the real world?
I’d love to hear your experience.

Zero-Ceremony Identity: Why I Built a Single-Binary OIDC Provider in Go

When I set out to build Auténtico, my primary goal was to create a fully-featured OpenID Connect Identity Provider where operational simplicity was the first-class design principle.

Identity infrastructure is notoriously complex. A typical self-hosted setup involves a database server, a cache tier like Redis, a worker queue, and the identity service itself. When I needed a lightweight OpenID Connect (OIDC) server to run on a small 2GB RAM VPS, I realized the existing landscape was either operationally exhausting or structurally flawed for my specific needs.

This is the story of how (and why) I built Auténtico, a self-contained, single-binary OIDC provider backed by SQLite that removes the ceremony from identity management.

The Itch: Finding the Right Lightweight IdP

My journey started because I was researching and implementing a frontend OIDC library for product needs at my company. That scratched an itch, and I evolved it into a functional backend OIDC protocol server in Go.

Months later, when I needed a lightweight Identity Provider, I evaluated the popular options but quickly hit roadblocks:

  • Casdoor: I didn’t like how they treated private data. Their demo instances recycle accounts every 5 minutes, making it impossible to truly test account deletion.
  • PocketId: This is a fantastic tool, but it had a critical UX flaw for my needs: it is passkey-only by default.

While passkeys are the future, the current ecosystem is heavily fragmented. If a user is on an older OS or a restrictive browser, a passkey-only IdP completely locks them out.

The Antidote: Zero-Ceremony Architecture

I decided to convert my OIDC protocol server into a full IdP, ensuring that every architectural decision was evaluated against a single question: does this reduce or increase the operational burden on the person running this?

Auténtico removes the entire traditional identity stack:

  • Single Binary: The entire IdP runs as one Go binary.
  • Embedded SQLite: There is no separate database server. The entire state lives in one file. Eliminating the external database removes connection pool tuning, credential rotation, and network partitions.
  • No External Infrastructure: No Redis, no Postgres, no message queues. Background cleanup goroutines automatically purge expired tokens, sessions, and auth codes.
  • Embedded UIs: Both the Admin dashboard (React/Ant Design) and the user-facing Account UI (React/Tailwind) are compiled directly into the binary using go:embed. There are zero separate frontend deployments.

Flexibility Over Dogma: Solving the Passkey Trap

To solve the hardware and OS fragmentation issues I experienced with passkeys, I ensured Auténtico wouldn’t trap operators into a single authentication path.

Instead, Auténtico offers three distinct authentication modes that are switchable at runtime without restarting the server:

  1. password
  2. password_and_passkey
  3. passkey_only

If you deploy passkey_only and discover your users’ specific browser combinations are failing, you can instantly flip a setting in the Admin UI to fall back to passwords. For robust security without passkeys, it includes standard fallback methods like TOTP (with in-browser QR enrollment) and Email OTP. For users with modern browsers, it fully supports hardware-backed FIDO2 authentication and even allows first-login registration in one seamless flow.

The “Deliberately Un-clever” Architecture & The AI Accelerator

To make this work, the codebase had to be deliberately un-clever. I designed a strict vertical-slice architecture where each package (like pkg/login or pkg/token) owns its exact slice of functionality with a predictable structure:

  • model.go
  • handler.go
  • service.go
  • Database CRUD

This strictness had a massive secondary benefit: it created the perfect environment for AI. Because I spent the time establishing this blueprint, I reached a tipping point where I could hand off the boilerplate. AI agents seamlessly followed the patterns to generate the CRUD operations and rapidly write over 700 tests (hitting 80% coverage) precisely because the architectural constraints were so rigid.

The Scale Ceiling (And Why It Doesn’t Matter)

The immediate pushback to this architecture is always: “SQLite doesn’t scale.”

I am intentionally honest about the scale ceiling: SQLite serializes writes. Auténtico is not designed for active-active multi-region deployments or massive enterprise horizontal scaling.

However, let’s look at the math:

Concurrency Error rate Login p95 Token p95 Assessment
20 VUs 0% 86ms 54ms Comfortable — imperceptible to users
100 VUs 0% 611ms 647ms Supported — fully functional
500 VUs 0% 3.36s 3.89s Degraded — users feel the wait

Performance tests with k6 show the system degrades gracefully via SQLite’s busy timeout—queueing requests and adding latency rather than throwing errors.

For most teams running internal tools, small-to-mid-sized apps, or self-hosted environments, trading infinite horizontal scaling for zero operational overhead is absolutely the right choice.

Conclusion

Operational simplicity does not mean protocol simplicity.

Auténtico strictly enforces:

  • OIDC Discovery — publishes /.well-known/openid-configuration so relying parties auto-configure without hardcoding endpoints
  • JWK Set — exposes public signing keys at /.well-known/jwks.json for independent token verification
  • RS256 JWT Signing — asymmetric signing; the private key never leaves the IdP
  • Auth2/OIDC protocol: ImplementsOIDC protocol
  • Admin UI: For admins to manage clients, users and session
  • Account UI: For users to manage they profile
  • Swagger OpenAPI docs: Publishes api specs docs

If you are a small team, an indie developer, or just someone who wants to deploy an Identity Provider without taking on a second job as a sysadmin, sometimes the best architecture is the one you barely have to think about.

Links

AI Can Not Be Left Unsupervised

“AI is dangerous” — is a phrase you may hear a lot, and I have to agree. But, the most dangerous words in tech right now aren’t “AI is dangerous“, they’re “it’ll probably be fine“. In a society where the use of AI has become the norm, people have become almost complacent around the dangers of it.

This week I read an article on Dev.to here, telling a story where an employee had “trusted AI to organize my backlog.” Two hours later, they returned to a development team’s worst nightmare.

“The agent had silently deleted 47 tickets it labelled as duplicates — they weren’t. It had reassigned half my team’s tasks to people who had left the company months ago. It created 23 new tickets for features nobody had requested. And it marked three critical bugs as resolved, because it found similar-sounding issues elsewhere in the system.”

A prime example of when AI is left to its own devices — no warning, no follow-up prompts. It was asked to organise a backlog, and so it did, in the only way it knew how.

Other Damaging Cases

This isn’t an isolated incident. Amazon’s own AI coding tool, Kiro, was handed a minor fix to a customer-facing system. Rather than patch it, the agent autonomously decided to delete and rebuild the entire environment from scratch. The resulting outage lasted 13 hours. Amazon’s response? “Coincidence that AI was involved.” That denial tells you everything.

In a similar case, an AI coding assistant from Replit went rogue and wiped the production database of startup SaaStr entirely — its founder took to social media to warn others after the assistant modified production code despite explicit instructions not to.

Could your business afford that kind of downtime?

It isn’t just agentic AI operating behind the scenes. Customer-facing AI is failing in equally public and embarrassing ways. DPD’s AI chatbot, after a routine system update, began swearing at a customer, insulted itself, and wrote a poem about how terrible the company was, all because a frustrated user simply asked it to. The incident went viral.

Perhaps the most alarming example of AI complacency comes from a Chevrolet dealership, whose chatbot agreed to sell a 2024 Chevy Tahoe for $1 and declared it a legally binding offer after a user simply manipulated it with a clever prompt. The dealer pulled the bot, but the damage to their brand was already done.

In August 2025, security researchers used a single 400-character prompt to manipulate Lenovo’s customer service chatbot into revealing sensitive company data including live session cookies from real support agents. Not a sophisticated hack. Just a simple prompt.

Outside of customer service, the professional world is sleepwalking into its own risks. A lawyer was caught citing entirely non-existent legal cases in a New York federal court filing he had used ChatGPT to conduct legal research, and simply trusted the output.

With the rapid adoption of AI across industries, has the world become complacent, siding with productivity over pragmatism? More often than not, users are blindly allowing AI to complete work for them, review sensitive documents outside the bounds of a secure environment, and make decisions without a single human checkpoint.

I know many developers who simply click “Accept” when prompted to approve terminal commands. I’ve spoken to professionals within my network who say “AI has evolved, it can be trusted.” That ignorance and naivety is precisely what leads to the scenarios above.

The companies and individuals that will thrive in an AI-driven world aren’t the ones adopting it the fastest. They’re the ones adopting it the most responsibly.

Final Note

Before you click “Accept” on that next AI suggestion, ask yourself honestly: do you actually know what you’re agreeing to? Don’t simply outsource your thinking to something that doesn’t think – it predicts.

AI isn’t the danger. We are.

The 8-Month Feature Nobody Wanted (Including Me, Eventually)

TL;DR: I built something so obsessively specific to my own workflow that I convinced myself it was universal. It wasn’t. The moment I stopped refreshing analytics, it shipped better.

The Problem I Solved (For Myself)

I was drowning in a particular flavor of repetitive work. Not a common problem—my problem. The kind where you’re the only one in your Slack thread who really gets why it’s broken. So I decided: I’ll build the fix.

Eight months. Custom parsing logic, a UI that only I understood, database schema decisions that made sense only because I lived in that headspace daily.

The Honest Part

I launched it to silence. Not criticism silence—indifference silence. I refreshed GitHub stars. Checked analytics every 6 hours. Posted in communities. Nothing. The feature was objectively good—it worked, it was fast, it solved the problem it was designed for.

But it was designed for an audience of one.

What Changed Everything

One afternoon I just… stopped checking. Deleted the analytics bookmark. Moved on to the next thing.

That’s when something weird happened: people started using it. Not because it went viral. But because the moment I stopped optimizing for adoption, I started shipping faster. I added features because I needed them. I fixed bugs I actually encountered. The product got honest.

Turns out people can smell when you’re building for them versus building for yourself. They prefer the latter.

What I Learned

  1. Specific beats universal. A tool built with religious specificity for one person often beats a generic solution built for “everyone.”
  2. Analytics are a trap. The refresh-check-despair loop kills momentum. You don’t need permission to build—you need focus.
  3. Shipping > perfecting. The moment I stopped trying to predict what others wanted, I built something worth using.

The real victory wasn’t adoption. It was finishing something.

If you’re sitting on a feature you’re “shocked nobody wants”—that’s not failure feedback. That’s a signal you’re too deep in your own head. Either go deeper (own your specificity) or ship it and forget about it. The middle ground—optimizing for invisible users—is where projects die.

What’s the feature you’ve been gatekeeping because you think nobody else cares?

How I Quickly Turn CSV Files Into Charts (Without Excel or Coding)

How I Quickly Turn CSV Files Into Charts (Without Excel or Coding)
A few days ago, I opened a CSV file thinking it would take me 2 minutes to visualize the data.
I was wrong.
I tried Excel… menus everywhere.
I thought about using Python… but that felt like overkill for a simple chart.
All I really wanted was this:
👉 Upload file → select columns → see a chart

The Problem

If you’ve worked with CSV files, you’ve probably faced this too:

  • Excel takes too many steps
  • Formatting breaks easily
  • Python requires setup and coding

It felt like there should be a simpler way.

What I Did

After trying a few options, I realized something:
There wasn’t a simple tool that just lets you upload a CSV and instantly see a chart.
So I built one.
It’s called DataPlotter - a simple tool where you can upload a CSV or Excel file and generate charts instantly in your browser.

👉 https://dataplotter.de

I originally built it just to solve my own frustration, but it turned out to be really useful for quick visualizations.

How It Works

1. Prepare Your CSV

Make sure:

  • First row has column names
  • Data is clean (no symbols like $ or commas)

Example:
X, Y
1, 5
2, 7
3, 6

2. Upload Your File
Upload your CSV or Excel file into the tool.

3. Select X and Y

Choose:

  • X-axis
  • Y-axis

Your chart appears instantly.

4. Customize (Optional)
You can:
Switch chart types
Add labels
Adjust colors

Final Thoughts

CSV files are everywhere, but raw data isn’t very helpful on its own.
For me, the goal was simple: remove friction.
No installs. No coding. No unnecessary steps.
That’s exactly why I built DataPlotter.
If you just want a quick chart without the usual hassle, this approach works really well.

Try it here
👉 https://dataplotter.de

How I used Next.js and Claude 3.5 to stop my PM from nagging me about Jira

We’ve all been there.

It’s 10:30 AM. You drop your update in the #daily-standup Slack channel: “Finally squashed that weird auth bug on the login portal, moving on to the database migration.”

Two hours later, your Product Manager DMs you: “Hey, awesome job on the auth bug! Did you remember to move the ticket to Done in Jira?”

Context switching is the absolute worst part of being a developer. We are already communicating our status naturally in Slack—why do we have to open a new tab, wait for Jira or Linear to load, find the right sprint, and click a button just to say the exact same thing?

Last weekend, I decided I’d had enough. I built a Slack bot that reads my daily updates, figures out which ticket I’m talking about, and auto-closes it for me.

Here is how I built it using Next.js, the Slack Events API, and Claude 3.5 Sonnet.

The Architecture

The flow is surprisingly simple, but the execution requires some strict timing:

  1. Developer posts a message in a monitored Slack channel.
  2. Slack fires a webhook to my Next.js API route.
  3. The API fetches the user’s currently open tasks from Jira/Linear.
  4. Claude 3.5 evaluates the Slack message against the open tasks to see if there’s a match.
  5. If confidence is > 95%, the bot hits the PM tool’s API to mark it “Done” and replies in Slack.

Challenge 1: Beating the Slack 3-Second Timeout

Slack’s Events API is notoriously strict. When they send a webhook payload, you have exactly 3 seconds to respond with a 200 OK, or Slack assumes your app is dead and retries the event (which leads to duplicate AI calls and chaos).

Because calling the Anthropic API and the Jira API takes way longer than 3 seconds, you cannot await the AI processing in the main thread.

To solve this in Next.js (deployed on Vercel), I used the waitUntil function from @vercel/functions. This allows the serverless function to return the 200 OK immediately to Slack, while keeping the execution environment alive to finish the AI pipeline in the background:

// src/app/api/webhooks/slack/route.ts
import { waitUntil } from '@vercel/functions';
import { NextRequest, NextResponse } from 'next/server';
import { processSlackMessage } from '@/lib/ai/pipeline';

export const POST = async (request: NextRequest): Promise<NextResponse> => {
  // ... verify signatures and parse payload ...

  const event = payload.event;

  if (event.type === 'message') {
    // 1. Queue the heavy AI processing in the background
    waitUntil(
      processSlackMessage({
        teamId: payload.team_id,
        channelId: event.channel,
        userId: event.user,
        messageText: event.text,
        messageTs: event.ts,
      }).catch(console.error)
    );
  }

  // 2. Immediately return 200 OK to satisfy Slack's 3-second rule
  return NextResponse.json({ ok: true });
};

Challenge 2: Stopping the AI from Hallucinating Tasks

The scariest part of this build was the idea of an AI accidentally closing the wrong ticket. If a developer says “I’m going to start working on the header tomorrow”, the AI shouldn’t close the “Header Redesign” ticket just because the words match.

To fix this, I engineered a highly specific system prompt for Claude 3.5. Instead of just asking “does this match?”, I force the LLM to return a strict JSON object with a calculated confidence_score.

I gave Claude very specific scoring guidelines:

// src/lib/ai/prompts.ts
const SYSTEM_PROMPT = `You are NudgeBot's task-matching engine. Given a Slack message and a list of the sender's open tasks, determine whether the message indicates that one or more tasks have been completed.

Return ONLY valid JSON in this exact shape:
{
  "matches": [
    {
      "task_id": "<uuid>",
      "confidence_score": <0.0–1.0>,
      "reasoning": "<1–2 sentences>"
    }
  ]
}

Scoring guidelines:
  0.95–1.00  Explicit: "finished X", "deployed X", PR merged for X
  0.80–0.94  Strong: past-tense action verbs ("shipped", "pushed") + context
  0.65–0.79  Moderate: implicit completion language, partial name overlap
  0.50–0.64  Weak: tangentially related, ambiguous phrasing

Rules:
- Past tense, "just", "finally", deployment language are positive signals.
- Questions, future tense ("will", "going to"), and negations ("not done") are NOT completions.
- Return empty matches if nothing matches above 0.50.`;

In the backend pipeline, if Claude returns a score of 0.95 or higher, the app automatically closes the ticket. If it’s between 0.70 and 0.94, the bot sends an ephemeral (private) message to the user in Slack with a button asking: “Looks like you finished the Auth Bug. Should I close the ticket for you?”

The “Trust” Feature (Audit Trails)

If you automate project management, your PMs will eventually get paranoid about why things are moving.

Whenever the bot closes a ticket, it makes a secondary API call to Jira/Linear to leave a comment on the ticket: ✅ Marked as done by NudgeBot based on a Slack update from @Aditya: "Finally squashed that weird auth bug."

Complete transparency, zero extra clicks.

Don’t want to build it yourself?

Building this was a super fun weekend project, but managing OAuth flows for Slack, Jira, Linear, Asana, and Trello is a massive headache.

Because we are developers and we appreciate not reinventing the wheel, I actually packaged this exact codebase into a 1-click installable SaaS.

If you want to stop your PMs from nagging you, you can invite the bot to your workspace and use it for free here: NudgeBot.xyz.

Let me know in the comments if you’d trust an AI to manage your project board!

Why Godot’s architecture makes it the best engine for AI-assisted development

Game engines weren’t designed with AI code assistants in mind. They were designed for humans clicking through visual editors. But some architectures happen to be far more readable to language models than others, and that gap matters now that 36% of game developers use AI tools at work (GDC 2026 State of the Industry).

Godot’s architecture is unusually good for AI-assisted workflows. Not because anyone planned it that way, but because the same design decisions that make Godot lightweight and hackable for humans also make it parseable and modifiable by language models.

Everything is human-readable text

Open a .tscn file (Godot’s scene format) in any text editor and you can read it:

[gd_scene load_steps=3 format=3]

[ext_resource type="Script" path="res://player.gd" id="1"]
[ext_resource type="Texture2D" path="res://icon.svg" id="2"]

[node name="Player" type="CharacterBody2D"]
script = ExtResource("1")
speed = 200.0

[node name="Sprite" type="Sprite2D" parent="."]
texture = ExtResource("2")

[node name="Collision" type="CollisionShape2D" parent="."]

Scripts are .gd files. Resources are .tres files. Project settings live in project.godot. All plain text, all version-controllable, all parseable by an LLM without any intermediate conversion step.

Compare that to Unity. Unity scenes are serialized YAML, but they reference objects by GUIDs that map to a separate .meta file for every asset in the project. A language model reading a Unity scene file sees something like m_Script: {fileID: 11500000, guid: a3b5d47f2c1e4a8b9d6f3e7c1a2b4d5e} and has no way to resolve what that GUID points to without crawling the entire asset database. Unreal stores assets in proprietary .uasset binary. AI tools working with Unreal typically need to operate at the C++ or Blueprint API level rather than reading project files directly.

This text-first approach means an AI assistant can read a Godot project the same way a developer does: open the files and understand the structure.

GDScript has one idiomatic way to do things

GDScript is a domain-specific language built for Godot. It has roughly 850 built-in classes with consistent naming conventions (snake_case everywhere, signals declared with the signal keyword, exported properties with @export).

That consistency matters for language models. When there’s one standard pattern for declaring movement logic, connecting signals, or building UI, the model doesn’t have to guess which of six possible approaches the developer is using.

extends CharacterBody2D

@export var speed := 200.0

func _physics_process(delta: float) -> void:
    var direction := Input.get_vector("left", "right", "up", "down")
    velocity = direction * speed
    move_and_slide()

This is the standard way to handle character movement in Godot 4. There’s no competing framework, no alternative input system, no dependency injection pattern. An AI model trained on Godot projects will see this pattern thousands of times.

Unity’s C# ecosystem is the opposite. You can use the old Input.GetAxis, the new Input System package, Rewired, InControl, or a custom abstraction. You can structure your project with MonoBehaviours, ScriptableObjects, ECS, or some hybrid. The language model has to figure out which stack the current project uses before it can write useful code.

The scene tree is a consistent data structure

Every Godot project is organized as a tree of nodes. A CharacterBody2D has a Sprite2D child and a CollisionShape2D child. A UI is a tree of Control nodes. This isn’t a convention; it’s the only way Godot works.

That structural consistency means an AI tool can reason about a Godot project at the architectural level. “Add a health bar to the player” translates to a deterministic operation: add Control children to the player’s scene tree, attach a script, connect the right signals. The AI doesn’t need to ask which UI framework you’re using because there’s only one.

Aspect Godot Unity Unreal
Scene format Text (.tscn) YAML with GUIDs Binary (.uasset)
Scripting GDScript (domain-specific) C# (general-purpose) C++ / Blueprints (visual)
Project structure Node tree (enforced) GameObject + Components (flexible) Actor + Component (flexible)
Plugin architecture GDExtension + EditorPlugin Package Manager + native plugins C++ modules + Blueprints
File readability Fully human-readable Partially (GUID references) Binary, not readable
Standard patterns ~1 way per task Multiple competing approaches Multiple competing approaches

The plugin system allows deep editor integration

Godot’s EditorPlugin API gives addons access to the editor itself: the scene tree dock, the inspector, the script editor, the output panel. A plugin can add custom docks, intercept editor events, and modify scenes programmatically.

This is why AI tools built for Godot can go beyond code generation. They can read the scene tree, create nodes, modify properties, and validate physics setups, all through the editor’s own API. Tools like Ziva use this to provide 30+ specialized tools that operate on the project at the engine level rather than just generating text in a chat window. That’s a fundamentally different capability than a generic AI assistant that can only paste code.

Unity’s editor is extensible too, but the extension points are more fragmented. Custom inspectors, editor windows, and asset processors each use different APIs. Unreal’s editor extension requires C++ and a full engine rebuild.

The open-source factor

Godot is MIT-licensed with over 102,000 GitHub stars. Every line of engine code is readable. When an AI tool needs to understand how a particular engine feature works internally, the source is right there.

This matters in practice. If an AI tool generates code that calls a Godot API in an unexpected way, a developer (or the AI itself) can read the engine source to understand why the behavior doesn’t match expectations. With proprietary engines, you’re limited to official documentation and community knowledge.

The open source model also means AI tools can be distributed as standard Godot plugins through the Asset Library, with no special licensing or SDK agreements. The barrier to building and sharing AI tools for Godot is just “write a plugin and publish it.”

This is already happening

The GDC 2026 State of the Industry report found that among developers who use AI, 81% use it for research and brainstorming and 47% use it for code assistance. Only 5% use AI for player-facing features. AI in game development is a productivity tool, not a content generator.

Godot’s architecture aligns with exactly those productivity use cases. The text-based file formats mean an AI can understand project structure. The consistent scripting language means generated code is more likely to be correct on the first try. The scene tree gives AI tools a reliable abstraction for reasoning about game architecture.

Godot went from 19% to 40% of GMTK Game Jam submissions between 2024 and 2025. Steam games shipped with Godot doubled year over year for three consecutive years. The engine is growing fast, and the developer tooling ecosystem is growing with it.

The engines that are easiest for AI to work with will attract the best AI tooling. And the engines with the best AI tooling will attract developers who want to move fast. Godot is positioned well on both counts.

Rust MCP Server Setup Guide for Vibe CLI

For anyone else trying to configure Mistral’s Vibe CLI combined with the Rust Analyzer MCP server (or any MCP server that runs into the naming conflict that the Rust Analyzer does) – hopefully this guide will save you the 5 hours it stole from my life.

This comprehensive guide documents all the steps required to get the Rust MCP server working with Vibe CLI, including fixes for naming conflicts, JSON parsing issues, and tool discovery problems.

Table of Contents

  1. Prerequisites
  2. Initial Setup
  3. Issue 1: Conflicting Binary
  4. Issue 2: JSON Parsing Errors
  5. Issue 3: Tool Name Prefixing
  6. Final Configuration
  7. Testing the Setup
  8. Troubleshooting
  9. Complete File Changes

Prerequisites

Before starting, ensure you have:

  • Rust toolchain installed (rustup, cargo)
  • Node.js 22.x+ (for some MCP servers)
  • Docker (for containerized MCP servers)
  • Vibe CLI installed
  • Git

Initial Setup

1. Clone the Rust MCP Server Repository

cd /Users/jordanhudgens/code/dashtrack/tools
git clone https://github.com/dexwritescode/rust-mcp
cd rust-mcp

2. Build the Server

cargo build --release

The binary will be located at: /Users/jordanhudgens/code/dashtrack/tools/rust-mcp/target/release/rustmcp

Issue 1: Conflicting Binary

Problem

Vibe CLI auto-discovers MCP servers in your PATH. If you have the official rust-analyzer-mcp binary installed via cargo, it will conflict with your custom server. I’m assuming it’s happening with other MCP servers, this is just the one I ran into.

Symptoms

  • Tools appear with rust-analyzer_* prefix
  • Naming conflicts between auto-discovered and configured servers
  • “Unknown tool” errors

Solution

Rename the conflicting binary:

mv ~/.cargo/bin/rust-analyzer-mcp ~/.cargo/bin/rust-analyzer-mcp-backup

Verification

which rust-analyzer-mcp  # Should return "rust-analyzer-mcp not found"
ls ~/.cargo/bin/rust-analyzer-mcp-backup  # Should show the renamed binary

Issue 2: JSON Parsing Errors

Problem

The Rust MCP server outputs plain text startup messages that break Vibe’s JSON-RPC parser.

Symptoms

Failed to parse JSONRPC message from server
pydantic_core._pydantic_core.ValidationError: 1 validation error for JSONRPCMessage
  Invalid JSON: expected value at line 1 column 1 [type=json_invalid, input_value='Starting Rust MCP Server', input_type=str]

Solution

Remove the println! statements from src/main.rs:

File: /Users/jordanhudgens/code/dashtrack/tools/rust-mcp/src/main.rs

// BEFORE (lines 12-13):
println!("Starting Rust MCP Server");
println!("Server running on stdio transport...");

// AFTER (remove these lines completely):
// No startup messages - MCP servers should only output JSON-RPC

Complete Fixed main.rs

use anyhow::Result;
use rmcp::{ServiceExt, transport::stdio};
use rustmcp::server::RustMcpServer;

#[tokio::main]
async fn main() -> Result<()> {
    // Initialize the rust-analyzer integration
    let mut rust_server = RustMcpServer::new();
    rust_server.start().await?;

    // Note: The #[tool] macros generate additional tools beyond our manual list
    // Start the MCP server using the ServiceExt trait
    let service = rust_server.serve(stdio()).await?;
    service.waiting().await?;

    Ok(())
}

Issue 3: Tool Name Prefixing

Problem

Initial assumption was that Vibe prefixes tool names with the server name (e.g., rustmcp_workspace_symbols), but this turned out to be incorrect.

Discovery Process

  1. Initial hypothesis: Vibe prefixes tools with server name
  2. Implemented stripping: Added splitn(2, '_').nth(1).unwrap_or(name)
  3. Found issue: Tools were being called as just symbols instead of workspace_symbols
  4. Realization: Vibe does NOT prefix tool names
  5. Final fix: Removed the prefix stripping logic entirely

Solution

Remove the prefix stripping logic from src/tools/types.rs:

File: /Users/jordanhudgens/code/dashtrack/tools/rust-mcp/src/tools/types.rs

// BEFORE:
pub async fn execute_tool(
    name: &str,
    args: Value,
    analyzer: &mut RustAnalyzerClient,
) -> Result<ToolResult> {
    // Strip any MCP host prefix (e.g. "rustmcp_analyze_manifest" → "analyze_manifest")
    let name = name.splitn(2, '_').nth(1).unwrap_or(name);
    match name {

// AFTER:
pub async fn execute_tool(
    name: &str,
    args: Value,
    analyzer: &mut RustAnalyzerClient,
) -> Result<ToolResult> {
    // Strip any MCP host prefix (e.g. "rustmcp_analyze_manifest" → "analyze_manifest")
    // Vibe doesn't actually prefix tool names, so we don't need to strip anything
    match name {

Final Configuration

1. Update Vibe Config

File: /Users/jordanhudgens/.vibe/config.toml

Add the Rust MCP server configuration:

[[mcp_servers]]
name = "rustmcp"
transport = "stdio"
command = "/Users/jordanhudgens/code/dashtrack/tools/rust-mcp/target/release/rustmcp"
args = []
startup_timeout_sec = 30.0

2. Rebuild the Server

cd /Users/jordanhudgens/code/dashtrack/tools/rust-mcp
cargo build --release

3. Restart Terminal

Close and reopen your terminal to ensure all environment changes take effect.

Testing the Setup

Test Basic Functionality

vibe

The Vibe CLI should start without JSON parsing errors.

Test Specific Tools

Workspace Symbols

rustmcp_workspace_symbols

With parameters:

{"query": "test"}

Find Definition

rustmcp_find_definition

With parameters:

{"file_path": "/path/to/file.rs", "line": 10, "character": 5}

Get Diagnostics

rustmcp_get_diagnostics

With parameters:

{"file_path": "/path/to/file.rs"}

Expected Results

  • No JSON parsing errors
  • Tools should execute and return proper responses
  • No “Unknown tool” errors
  • Server should be discovered as rustmcp with all available tools

Troubleshooting

“Unknown tool” Errors

Cause: Tool name mismatch or server not properly initialized.

Solution:

  1. Verify the server is running: ps aux | grep rustmcp
  2. Check tool names in the server code
  3. Ensure no prefix stripping logic is active

JSON Parsing Errors

Cause: Server outputting non-JSON text.

Solution:

  1. Remove all println!, eprintln!, and print! statements
  2. Ensure only JSON-RPC messages are output
  3. Check for any debug statements that might output to stdout

Server Not Discovered

Cause: Configuration issue or binary path incorrect.

Solution:

  1. Verify the binary path in config.toml
  2. Ensure the binary is executable: chmod +x /Users/jordanhudgens/code/dashtrack/tools/rust-mcp/target/release/rustmcp
  3. Check for typos in the server name

Conflicting Tools

Cause: Multiple MCP servers with overlapping tool names.

Solution:

  1. Ensure no other rust-analyzer-mcp binary exists in PATH
  2. Check for auto-discovered servers with vibe --debug
  3. Remove or rename conflicting binaries

Complete File Changes

1. /Users/jordanhudgens/code/dashtrack/tools/rust-mcp/src/main.rs

Before:

use anyhow::Result;
use rmcp::{ServiceExt, transport::stdio};
use rustmcp::server::RustMcpServer;

#[tokio::main]
async fn main() -> Result<()> {
    // Initialize the rust-analyzer integration
    let mut rust_server = RustMcpServer::new();
    rust_server.start().await?;

    // Note: The #[tool] macros generate additional tools beyond our manual list
    println!("Starting Rust MCP Server");
    println!("Server running on stdio transport...");

    // Start the MCP server using the ServiceExt trait
    let service = rust_server.serve(stdio()).await?;
    service.waiting().await?;

    Ok(())
}

After:

use anyhow::Result;
use rmcp::{ServiceExt, transport::stdio};
use rustmcp::server::RustMcpServer;

#[tokio::main]
async fn main() -> Result<()> {
    // Initialize the rust-analyzer integration
    let mut rust_server = RustMcpServer::new();
    rust_server.start().await?;

    // Note: The #[tool] macros generate additional tools beyond our manual list
    // Start the MCP server using the ServiceExt trait
    let service = rust_server.serve(stdio()).await?;
    service.waiting().await?;

    Ok(())
}

Changes: Removed lines 12-13 (println! statements)

2. /Users/jordanhudgens/code/dashtrack/tools/rust-mcp/src/tools/types.rs

Before:

pub async fn execute_tool(
    name: &str,
    args: Value,
    analyzer: &mut RustAnalyzerClient,
) -> Result<ToolResult> {
    match name {

After:

pub async fn execute_tool(
    name: &str,
    args: Value,
    analyzer: &mut RustAnalyzerClient,
) -> Result<ToolResult> {
    // Strip any MCP host prefix (e.g. "rustmcp_analyze_manifest" → "analyze_manifest")
    // Vibe doesn't actually prefix tool names, so we don't need to strip anything
    match name {

Changes: Added comment explaining that no prefix stripping is needed

3. /Users/jordanhudgens/.vibe/config.toml

Added:

[[mcp_servers]]
name = "rustmcp"
transport = "stdio"
command = "/Users/jordanhudgens/code/dashtrack/tools/rust-mcp/target/release/rustmcp"
args = []
startup_timeout_sec = 30.0

4. Renamed Binary

mv ~/.cargo/bin/rust-analyzer-mcp ~/.cargo/bin/rust-analyzer-mcp-backup

Available Rust MCP Tools

The Rust MCP server provides the following tools:

Analysis Tools

  • find_definition – Find the definition of a symbol
  • find_references – Find all references to a symbol
  • get_diagnostics – Get compiler diagnostics for a file
  • get_type_hierarchy – Get type hierarchy for a symbol
  • validate_lifetimes – Validate and suggest lifetime annotations

Navigation Tools

  • workspace_symbols – Search for symbols in the workspace
  • find_definition – Find symbol definitions

Refactoring Tools

  • rename_symbol – Rename a symbol with scope awareness
  • extract_function – Extract selected code into a new function
  • inline_function – Inline a function call
  • change_signature – Change the signature of a function
  • organize_imports – Organize and sort import statements

Code Generation Tools

  • generate_struct – Generate a struct with specified fields
  • generate_enum – Generate an enum with specified variants
  • generate_trait_impl – Generate a trait implementation
  • generate_tests – Generate unit tests for a function

Project Tools

  • analyze_manifest – Parse and analyze Cargo.toml
  • run_cargo_check – Execute cargo check
  • suggest_dependencies – Suggest crate dependencies
  • create_module – Create a new Rust module
  • move_items – Move code items between files

Formatting Tools

  • format_code – Apply rustfmt formatting
  • apply_clippy_suggestions – Apply clippy lint suggestions

Maintenance

Updating the Server

cd /Users/jordanhudgens/code/dashtrack/tools/rust-mcp
git pull origin main
cargo build --release

Reapplying Fixes After Update

If you update the server and the issues reappear, reapply the fixes:

  1. Remove startup messages (if they were re-added):
   # Edit src/main.rs and remove any println! statements
  1. Ensure no prefix stripping (if it was re-added):
   # Edit src/tools/types.rs and remove any prefix stripping logic
  1. Rebuild:
   cargo build --release

Conclusion

This guide documents the complete process to get the Rust MCP server working with Vibe CLI. The key issues were:

  1. Conflicting binary in PATH causing auto-discovery conflicts
  2. JSON parsing errors from startup messages breaking the protocol
  3. Incorrect assumption about tool name prefixing leading to unnecessary stripping logic

With these issues resolved, the Rust MCP server provides full Rust analysis capabilities directly within Vibe CLI.

References

I built a health check for inherited codebases — and vibe-coded apps that now became important

I have just launched repowatch — repowatch.io 🎉

A quick, lightweight health check for your (or inherited) codebase.
Simply upload a .zip or connect to your GitHub and Gitlab repos and get a scorecard across: code quality, test confidence, security basics, and AI-risk signals. Heck, Download a full recommendations report in paid tiers, have at it.

Built for the slightly chaotic reality of inherited repos, shadow AI, and vibe-coded apps that somehow ended up important. It’s still early, but live and looking forward to any feedback.

Keen to see whether this actually lands for teams dealing with that kind of challenge. If you want to give it a spin, use code TESTWEEK at checkout for 99%! 😲 off your first month, but only until 1 April.

Thanks dev.to community,