A clothing pairing app

This is a submission for the Gemma 4 Challenge: Build with Gemma 4

What I Built

A clothing pairing app — upload a photo of one piece you own, get a complementary match from a curated catalogue, then see both styled together in an AI-generated editorial image.

The product catalogue is the Myntra fashion dataset (~44k products), available at kaggle.com/datasets/paramaggarwal/fashion-product-images-dataset.

Demo

Code

Pair.

A clothing pairing app — upload a photo of one piece you own, get a complementary match from a curated catalogue (which is downloaded at https://www.kaggle.com/datasets/paramaggarwal/fashion-product-images-dataset), then see both styled together in an AI-generated editorial image.

How it works

  1. Upload a photo of a top, bottom, shoes, or accessory
  2. Analyze — Gemma 4 identifies the piece: category, gender, style, palette
  3. Match — a complementary item is selected from ~44k Myntra products via MongoDB + Gemma 4
  4. Lookbook — Gemini generates a fashion editorial photo of both pieces styled on a model

Stack

Layer Tech
Frontend Vite + React + TypeScript + Tailwind CSS v4
Backend Hono on Node.js
AI Google GenAI SDK (Gemma 4 + Gemini)
Database MongoDB (Myntra product dataset)
Bot protection Google reCAPTCHA v3

Prerequisites

  • Node 24 (use nvm use — .nvmrc is included)
  • MongoDB running locally
  • Google AI Studio API key — aistudio.google.com
  • Google reCAPTCHA…

How I Used Gemma 4

Gemma 4 (gemma-4-31b-it) is used for two tasks where we need text output from a structured prompt, not image generation:

Clothing analysis — given an image, return a JSON object with category, gender, style, colors, etc.
Match selection — given 30 product candidates from MongoDB, pick the best one and return a JSON object with the index, match score, and stylist note.

E2B vs E4B vs 31B Dense: The Practical Guide to Choosing the Right Gemma 4 Model

This is a submission for the Gemma 4 Challenge: Write About Gemma 4

I want to tell you about the moment I stopped treating local AI as a compromise.

I was building a UX analysis tool. The idea was simple: give it a screenshot, get back a structured critique grounded in Nielsen’s heuristics, cognitive load theory, WCAG — the real frameworks, not vibes. I’d used cloud models before. They work. But they also mean your users’ screenshots leave their machine, hit an API, and cost money per call.

So I pulled Gemma 4 E4B locally via Ollama, pointed it at Stripe.com, and waited.

Fifty-six seconds later it handed me a structured JSON object with an overall UX score, three specific friction points each mapped to a Nielsen heuristic, accessibility flags, layout analysis, and prioritized recommendations with effort and impact ratings.

On CPU. No GPU. No API key. No data leaving the machine.

That’s when the compromise framing broke for me. This isn’t a fallback. It’s a capability shift.

What Gemma 4 Actually Is

Gemma 4 is Google’s latest open-weights model family, and the headline feature is multimodality — it can see images, not just read text. But the more interesting story is the range of what Google shipped:

Variant Parameters Context Window Best For
E2B ~2B 8K Edge, mobile, fast inference
E4B ~4B 8K Local development, CPU-viable
31B Dense ~31B 128K Complex reasoning, long documents

Three very different tools with the same name. Picking wrong costs you either quality or speed. Let me tell you what I’ve actually learned about each.

E2B: The One That Fits Anywhere

The 2-billion-parameter model is built for constraints. Think browser extensions, mobile apps, Raspberry Pi, anything where you cannot afford to wait or cannot carry a heavy runtime.

It handles basic instruction-following and short-form generation well. What it struggles with is structured output — complex JSON schemas, multi-step reasoning chains, tasks that require holding a lot of context simultaneously. If your task is “summarize this paragraph” or “classify this text into one of five categories,” E2B is excellent and blazingly fast. If your task requires the model to reason across multiple frameworks while producing a nested JSON object, you’ll feel the ceiling.

Use E2B when: speed > depth, you’re running on constrained hardware, or the task is classifying/summarizing rather than reasoning.

E4B: The Sweet Spot for Developers

This is the one I use. The 4-billion-parameter model is the first point in the family where you stop noticing the limitations on most practical tasks.

What changed for me: E4B can follow a complex, multi-section JSON schema reliably. I give it a system prompt that defines eight nested fields — cognitiveLoad, trustScore, frictionPoints, recommendations, accessibilityFlags, layoutAnalysis — and it fills them correctly, with real observations, not hallucinated filler.

It also handles format: "json" mode in Ollama properly. This flag constrains the output to valid JSON, and E4B respects it while still producing coherent, specific content. E2B can struggle here — the output is valid JSON but the reasoning quality inside the strings drops noticeably.

On a modern CPU (M-series Mac or reasonably recent x86), E4B runs in under two minutes for most tasks. That’s usable. It’s not instant, but it’s not “go make a coffee” either. On a GPU it’s much faster.

Use E4B when: you need structured output, multimodal input, or reasoning quality that holds up in a real product — and you want it to run locally without a GPU.

31B Dense: When You Need to Think Hard

The 31B model is a different kind of tool. The 128K context window alone opens up use cases the smaller models simply cannot touch: full codebase analysis, long-document reasoning, multi-turn conversations that span thousands of tokens without losing thread.

The reasoning depth is also qualitatively different. Tasks that require synthesizing across many pieces of information — legal document review, detailed technical explanations, nuanced comparative analysis — land differently at 31B than at 4B. The model doesn’t just answer; it considers.

The tradeoff is infrastructure. You need a GPU (or significant RAM for CPU inference), and the latency is real. This isn’t a laptop model. But if you’re building something that needs serious reasoning and you can provision the compute, 31B Dense is competitive with much larger proprietary models on a lot of benchmarks.

Use 31B Dense when: you have the compute, your task requires deep reasoning or long context, and quality is the primary constraint.

The Feature That Changed How I Build: Multimodal + JSON Mode

Here’s the combination that makes Gemma 4 genuinely special for application developers.

Most LLM applications are text in, text out. You send a prompt, you parse the response, you deal with whatever format the model decided to use that day. Gemma 4 changes two things at once:

1. You can send images. Not OCR’d text from images. Not bounding boxes. The actual image. The model reasons about what it sees — colors, layout, spatial relationships, visual hierarchy.

2. You can enforce JSON output. With format: "json" in the Ollama request, the model outputs valid JSON. Combined with a well-defined system prompt that describes the exact schema you want, you get structured, machine-readable output you can render directly in a UI.

Here’s what that looks like in practice:

const response = await fetch("http://localhost:11434/api/generate", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    model: "gemma4:e4b",
    prompt: systemPrompt + "nn" + userPrompt,
    images: [base64Image],  // raw base64, no data URI prefix
    format: "json",         // constrain output to valid JSON
    stream: true,
    options: {
      temperature: 0.3,     // lower = more deterministic structure
      num_ctx: 8192,
    },
  }),
});

The model receives the image, reasons about it, and returns a structured JSON object. No parsing heuristics. No regex. No “hope the model uses the format I asked for.” Validate it with Zod or any schema library and you’re done.

One practical note: sometimes the first pass at temperature: 0.3 produces a malformed response, especially on complex schemas. A simple retry at temperature: 0.1 with streaming disabled almost always fixes it:

// Retry pattern for malformed JSON
const retryResponse = await fetch("http://localhost:11434/api/generate", {
  method: "POST",
  body: JSON.stringify({
    model: "gemma4:e4b",
    prompt: systemPrompt + "nn" + userPrompt,
    images: [base64Image],
    format: "json",
    stream: false,          // non-streaming for retry
    options: { temperature: 0.1, num_ctx: 8192 },
  }),
});

This two-pass pattern makes structured multimodal output production-reliable.

Getting Started in Five Minutes

If you want to run Gemma 4 locally right now:

# Install Ollama (macOS/Linux)
curl -fsSL https://ollama.com/install.sh | sh

# Pull the E4B model (~3GB download)
ollama pull gemma4:e4b

# Test it immediately
ollama run gemma4:e4b "Explain the difference between cognitive load and visual noise in UX design"

For multimodal input via API:

# Send an image (base64 encoded)
curl http://localhost:11434/api/generate -d '{
  "model": "gemma4:e4b",
  "prompt": "Describe the visual hierarchy in this UI screenshot",
  "images": ["'$(base64 -i your-screenshot.png)'"],
  "format": "json",
  "stream": false
}'

That’s it. No API key, no rate limits, no billing dashboard.

What This Actually Means

I think we’re underreacting to what’s happening here.

A 4-billion-parameter multimodal model that runs on a consumer laptop, follows complex structured output schemas, reasons about images, and produces output good enough to use in a real product — that’s not a prototype capability. That’s a production capability.

For developers, this changes the build calculus for a whole category of applications. Anything that involves user data you’d rather not send to a cloud API. Any tool that needs to work offline. Any product targeting users who are rightfully skeptical about where their data goes. Any hobby project where you don’t want to watch a usage bill grow.

For the broader ecosystem, a capable open-weights multimodal model means researchers, educators, and developers in places with expensive or unreliable internet access can run serious AI locally. The capability is no longer gated behind an API key and a credit card.

The question I keep coming back to: if a 4B model running on CPU can do this now, what does the roadmap look like in two years? We’re probably not near the ceiling.

I’m not sure what that means yet. But I’m fairly sure the “local AI as compromise” framing is already wrong, and most people haven’t noticed.

Running Gemma 4 E4B locally via Ollama. Models are open-weights and available at ollama.com/library/gemma4.

I built an AI app store screenshot generator because Figma made me cry — looking for brutal feedback

I’m Rishab. Every time I shipped an iOS app, I’d lose 2 days fighting Figma
trying to make App Store screenshots that didn’t look amateur. I’m a dev,
not a designer, and the existing tools were either $200/mo Figma plug-ins
or $500 design gigs per launch.

So I built SnapMonk — drop in a raw screenshot, it picks a layout, writes
the tagline copy with AI, mocks a device frame, and exports every size
the App Store and Play Store ask for. Free tier has no watermark. Pro
adds team features + unlimited saves.

Where I’m at:

  • 33 signups in 5 weeks
  • 2 paid users
  • Just got featured on Product Hunt

What I’d love feedback on:

  1. The editor flow — anything slow, confusing, or stupid?
  2. Pricing — is free tier too generous? Too thin?
  3. Where else should an indie like me be posting? (Already on PH, AlternativeTo)

URL: https://snapmonk.com
No login needed to try it.

Roast me — that’s what I’m here for.

Hello DEV Community — My Developer Journey Begins

Hello everyone! I’m a web developer passionate about building modern, clean, and user-friendly web applications.

I’ve recently joined the DEV Community to share my journey, learn from other developers, and document everything I build along the way.

💻 What I’m Focused On

Right now, I’m exploring and improving my skills in:

  • 🌐 Frontend Development (HTML, CSS, JavaScript)
  • ⚙️ Building responsive and interactive UI/UX designs
  • 🔌 API integration and backend basics
  • 🤖 AI-powered web tools and automation ideas
    I enjoy turning ideas into real working products that people can actually use.

🛠️ What I Love Building

I’m especially interested in building:

Web tools (like converters, generators, and utilities)
AI-based automation tools
Modern landing pages and SaaS-style UI designs
Small but useful projects that solve real problems
📚 Why I Joined DEV

I joined DEV Community because I want to:

Share my learning journey openly
Improve my development skills through consistency
Connect with other developers
Get feedback on my projects
Stay motivated by building in public
🔥 What You Can Expect From Me

From here on, I’ll be posting:

My web development projects
UI/UX experiments
AI tool ideas and builds
Tips and lessons I learn while coding
🌱 Final Words

I’m just getting started, but I’m excited about this journey of building, learning, and growing as a developer.

If you’re also on a similar path, feel free to connect — I’d love to learn together!

Thanks for reading 🙌

Adaptable apps on ChromeOS: a post-mortem

In my previous article Building a custom launcher for ChromeOS I described how Be nice runs on Chromebooks: not as a real default home app, because default home settings (Settings.ACTION_HOME_SETTINGS) usually are not available on ChromeOS, but as a normal Android app in an ARC window. I pretended the app is the launcher in code (detectIsHomeApp() returns true on ChromeOS), worked around split-screen bugs that leave the app a black rectangle, and gave up transparent wallpaper for an opaque scaffold because ARC does not show the ChromeOS desktop behind the window the way a phone shows its wallpaper.

What that article did not cover is the fight that came right after. For about a week in May 2026 I tried to get rid of a system confirmation ChromeOS shows when users open the preset-size menu in the ARC window title bar and choose Resizable, at least on Play Store builds. During my experiments, manifest changes often looked fine from Android Studio; however, on Play, the dialog stayed. I tried adding XML, asking AI assistants, including Google’s own models, for the magic flag. Here’s what I learned along the way.

What users see

ChromeOS does not present Play Store Android apps the same way in every posture. On a convertible or clamshell Chromebook in laptop use, ARC usually places apps in individual windows (chromeos.dev). The title bar often labels the current layout (Phone, Tablet, or Resizable) and opens a menu to switch presets; choosing Resizable is what triggers the warning below, not dragging the window frame by itself (in fact, resizing the window by dragging one of the window edges does not work until it is allowed through the dialog). In tablet mode the picture is different: apps commonly launch and stay full screen, which matches how ChromeOS has long treated touch-first use on detachables (Chrome Unboxed on immersive mode). The resize presets and the warning below matter most when you are in a windowed, laptop-style layout, not when an app is already filling the screen in tablet mode.

From that menu, when you choose Resizable in a windowed, laptop-style layout, ChromeOS brings up a confirmation that has been written about since around 2021 (Chrome Unboxed, Chrome Nerd):

This app is designed for mobile and may not resize well. The app may experience issues or restart.

Some apps never offer Tablet or Resizable at all; they stay on Phone with “This app only supports this size”, which is the stricter case when developers set resizeableActivity="false" (About Chromebooks). Be nice was not locked like that; Tablet and Resizable appeared in the title-bar menu, but the mobile warning still appeared when I chose Resizable on Play installs.

What made debugging miserable was Studio versus Play. Sideloads felt better; store builds did not. Play Help describes Phone, Tablet, and Resizable presets accessible directly from the window title bar, which matches how I used them on my Chromebook, though the exact entry point may differ by ChromeOS version. Play Help also says resize presets apply to newly downloaded apps, so I shipped many builds in quick succession. Fortunately there is an internal testing track. Never do this in production.

What I started with

The manifest did not spell out ChromeOS windowing. There was no explicit resizeableActivity, no ChromeOS window metadata, and no optional touchscreen or PC hardware features. On ordinary Android that omission was deliberate: for apps targeting API level 24 and higher, resizeableActivity defaults to "true" when you leave it out (<application>, <activity>). Be nice targets API 37 with minSdk 28, so phone and tablet builds were already resizable in multi-window terms; I had never needed the attribute in XML. What I lacked were the Chromebook-specific hints, not the baseline Android flag. The launcher already listened for size-related configChanges, including smallestScreenSize, but I had not told ChromeOS how I wanted freeform launch or Play classification on devices without touch. The working theory was simple: add the flags Google documents for Chromebooks, and the dialog would go away.

What I tried

Declare the app resizable. I set android:resizeableActivity="true" on the application and told Play users I had “improved detection of the app’s resize capabilities on ChromeOS.” On Android I had mostly made explicit what the target SDK already implied; it did not unlock resize on phones or tablets that was missing before. It does not remove the “designed for mobile” prompt when someone picks Resizable on ChromeOS either. I had conflated “allowed to resize” with “ChromeOS will not warn.”

Tell the system I support size changes. I added android.supports_size_changes at the application level, later duplicated it on individual activities, and at one point switched from <meta-data> to <property>. The hope was that ChromeOS would treat the app as resize-aware and skip the warning. Nothing in the public docs ties that flag to suppressing the dialog; the warning outlived every variant.

Ask for a tablet-shaped launch. I set WindowManagerPreference:FreeformWindowSize to tablet on the home activity so the window would open in a tablet preset rather than phone-sized mobile. Changelog copy spoke of “default tablet window size.” That metadata only affects how the window opens in freeform mode (chromeos.dev), not whether the user sees the mobile warning when they switch to Resizable. I later removed the tablet preset while still chasing the same popup, so I had already abandoned my own hypothesis.

Pin static launch bounds. I added <layout> on the launcher (and eventually on other activities) with default width and height in dp; I tried 840×640 first, then larger bounds as the experiment went on. At the same time I still had, or had just had, tablet freeform metadata on the same activity. Documentation warns that static <layout> bounds and FreeformWindowSize can conflict; I ran both anyway. Without success, that is.

Declare Chromebook-friendly hardware. Following a suggestion from Gemini, I marked touchscreen, faketouch, and android.hardware.type.pc as not required, and added <supports-screens> with every size bucket enabled. The touchscreen and PC entries are appropriate for Play on Chromebooks (manifest compatibility), but they address installability and input, not the resize confirmation string.

Widen configChanges to avoid restarts. I started with a modest list, then expanded it, then trimmed it back down. At one point the manifest listed almost every flag I could find, including locale, font scale, and color mode, on the theory that activity restarts on resize triggered bad behavior or the warning. It became unreadable, and it did not help.

Activity embedding override. I even added PROPERTY_ACTIVITY_EMBEDDING_ALLOW_SYSTEM_OVERRIDE at the application level. That belongs to a different feature (embedding / multi-pane); it has no documented link to ChromeOS freeform resize warnings, and, to no one’s surprise, it did not help.

Time to recap

Declaring resizeableActivity tells the system the app accepts window resize (rather than staying in a fixed compatibility viewport on large screens); it does not opt out of the Resizable confirmation. supports_size_changes and launch metadata do not either. Optional touchscreen declarations matter for Chromebook distribution; they were never documented as silencing “designed for mobile.” The dialog fits Google’s preset-size UX, which is a way for users to pick Phone or Tablet without every app being fully adaptive (Android Community). I had spent a week tuning manifest knobs for a message the OS shows on purpose when the user chooses Resizable, and for which I have not found an officially documented, bulletproof way out.

The biggest learning was not to underestimate what changes when the app comes from a Play install. Sideloading from Android Studio had led me to think the problem was solved; the store build told a different story. Play distribution, install context, and ChromeOS window presets are part of the behavior you are debugging, and they are not fully visible if you only deploy from the IDE.

Nothing in this effort demonstrated that the “designed for mobile” dialog can be removed from the manifest alone. I continue with a manifest that reflects adaptability on Android in general: resizeableActivity on the application, android.supports_size_changes on the home activity, optional touchscreen / type.pc hardware declarations for desktop-class devices, and size-related configChanges as chromeos.dev‘s second option for window reshape. The adaptive UI itself stays in Compose (WindowSizeClass and layouts that follow the window).

That is the story in prose; here is what it looks like in app/src/main/AndroidManifest.xml today:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:tools="http://schemas.android.com/tools"
    xmlns:android="http://schemas.android.com/apk/res/android">

    <queries>
        <intent>
            <action android:name="android.intent.action.MAIN" />
            <category android:name="android.intent.category.LAUNCHER" />
        </intent>
    </queries>

    <uses-feature
        android:name="android.hardware.touchscreen"
        android:required="false" />
    <uses-feature
        android:name="android.hardware.faketouch"
        android:required="false" />
    <uses-feature
        android:name="android.hardware.type.pc"
        android:required="false" />

    <application
        android:name=".BeNiceApplication"
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:enableOnBackInvokedCallback="true"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:resizeableActivity="true"
        android:supportsRtl="true"
        android:theme="@style/Theme.BeNice"
        tools:ignore="UnusedAttribute">

        <activity
            android:name=".AppChooserActivity"
            android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize"
            android:exported="true"
            android:launchMode="singleTask"
            android:stateNotNeeded="true"
            android:windowSoftInputMode="adjustResize">
            <meta-data
                android:name="android.supports_size_changes"
                android:value="true" />
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.HOME" />
                <category android:name="android.intent.category.DEFAULT" />
            </intent-filter>
        </activity>

        <activity
            android:name=".ImageViewerActivity"
            android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize"
            android:exported="false">
            <intent-filter>
                <action android:name="android.intent.action.QUICK_VIEW" />
            </intent-filter>
        </activity>

        <activity
            android:name=".BeNiceActivity"
            android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize"
            android:excludeFromRecents="true"
            android:exported="true">
            <intent-filter>
                <action android:name="de.thomaskuenneth.benice.intent.action.ACTION_LAUNCH_APP" />
            </intent-filter>
            <intent-filter>
                <action android:name="de.thomaskuenneth.benice.intent.action.ACTION_LAUNCH_APP_PAIR" />
            </intent-filter>
        </activity>

        <service
            android:name=".BeNiceTileService"
            android:exported="true"
            android:icon="@drawable/tile_service_icon"
            android:label="@string/app_name"
            android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
            <intent-filter>
                <action android:name="android.service.quicksettings.action.QS_TILE" />
            </intent-filter>
        </service>

        <receiver
            android:name=".ShortcutPinnedReceiver"
            android:exported="false" />

    </application>

</manifest>

If you strip away the experiments, two resize declarations and one documented fallback are still worth keeping, and none of them is the magic flag that silences the dialog:

  • android:resizeableActivity="true" on <application>. Google expects apps that behave well when resized to declare resizeableActivity or supports_size_changes as enabled (device compatibility mode). At my target SDK the default is already true; I keep it explicit anyway.

  • <meta-data android:name="android.supports_size_changes" android:value="true" /> on AppChooserActivity. Same guidance: declare that the activity handles size changes without fighting compatibility mode. I put it on the home activity, not on every screen.

  • android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize" on activities. chromeos.dev lists this as its second option for resize; the first is ViewModel plus saved state so activity recreation stays cheap. I use the flags to skip a full tear-down when the ARC window changes shape. Current adaptive app guidance puts currentWindowAdaptiveInfo() and responsive Compose layouts first; I do not override onConfigurationChanged, and I am not claiming this manifest line is the adaptability strategy. It is optional lifecycle plumbing on top of WindowSizeClass.

The arc is not a win. It means accepting that Google never documented a clean way out of the Resizable warning, that Play behavior on Chromebooks stays largely opaque until you ship and test there, and that ChromeOS itself feels like a platform Google is letting fade while Aluminium OS and unified Android desktop take the spotlight.

What’s more, there is that feeling of being treated unfairly. Google pushes developers hard on making their apps behave well on as many device categories and form factors as possible, yet even first class citizens are stigmatised by this popup.

Has anyone found a workaround, or is this firmly an OS-level restriction? Let me know in the comments if you’ve felt this pain, too.

References

The WordPress Paradox: Why It’s Here to Stay (and How to Stop Ruining It)

Let’s be honest. If you are fluent in React, TypeScript, and modern backend architectures, working on WordPress feels like taking a massive step backward. For years, developers have been predicting its death, claiming modern stacks would completely wipe it out.

But it’s 2026, and WordPress still powers over 40% of the internet. It isn’t going anywhere.

As developers, we have two choices: we can keep complaining about it, or we can understand why businesses love it and learn how to engineer it properly.

The Real Problem: Plugin Abuse

WordPress gets a bad reputation, but the fault usually lies with the people building it. Too many developers (and clients) treat the plugin directory like a candy store.

Need a Google Analytics tag? Install a plugin. Need a basic contact form? Install a plugin. Need to compress an image? Install another one.

Before you know it, the site is running 40+ heavy plugins. They constantly fight for database resources, conflict with each other during updates, create massive security vulnerabilities, and turn the user experience into a slow, bloated mess.

Why Clients Won’t Move On

We love our frameworks, but clients care about business outcomes, not our tech stacks. WordPress wins because of two things:

The Non-Technical Content Editor: Marketing teams know how to use the dashboard. They want to fix a typo or publish a landing page instantly without opening a GitHub ticket or waiting for a developer to deploy code.

The Mature Ecosystem: Spinning up standard features like WooCommerce or Advanced Custom Fields (ACF) takes days, whereas building those from scratch in a custom stack takes weeks of expensive engineering time.

The Modern Compromise: Headless WordPress

You don’t have to build monolithic, slow PHP sites to give clients what they want. The modern way to handle this is Headless WordPress.

You keep WordPress strictly as a backend Content Management System (CMS). The marketing team gets their familiar login dashboard to manage posts. But instead of letting PHP render the front-end, you pull that content via the WordPress REST API or WPGraphQL.

Then, you build the actual website using Astro, Next.js, or Remix. The end-user gets a blazing-fast, secure, compile-on-demand static or React site, and the client still gets their favorite editor. It’s a win-win.

How to Keep Monolithic WordPress Lean (Without 50 Plugins)

If you do have to build a traditional monolithic WordPress site, you can easily keep it from choking if you stick to a few senior development rules:

  • Use functions.php for simple tasks: If you need to register a custom post type, inject a tracking script, or modify a minor layout behavior, just write a few clean lines of native PHP. You don’t need a separate plugin for things that take five lines of code.

  • Ditch heavy page builders: Avoid tools like Elementor or Divi that inject endless nested tables, bloated CSS, and unnecessary DOM elements. Use native Gutenberg blocks paired with Advanced Custom Fields (ACF) to give clients flexible layouts that remain incredibly lightweight.

  • Offload to the Edge: Stop using heavy security and optimization plugins that run massive, unoptimized cron jobs on your origin server. Put the site behind Cloudflare and let the network edge handle caching, web application firewalls (WAF), and image optimization.

The Takeaway

WordPress isn’t a tech stack problem; it’s an engineering discipline problem. Once we stop fighting the fact that it’s here to stay, we can start using modern architecture to make it fast, secure, and actually pleasant to work with.

I built a local voice AI that can change to 9 different personalities!

i made this AI called ShiftAI, a voice AI, but it is not for assisting, it has the ability to switch personalities. it has over 9 personalities like: Mean, Depressed, philosophical and it can even turn into tung tung tung sahur!
you can change its personality by saying: change your personality to (the one you want)
and if you want to go back to normal just say: change back to normal or go back to normal
all of the explanations are on the site and a better explanation (even a demo!)
site was made with HTML AND CSS, and the app was made with python + tkinter. uses Groq API for responses.
And also the site might look messy on a phone and I used tkinter which I’m pretty sure won’t work on phones so if you’re on a phone you unfortunately can’t get this app.
would love feedback!


ShiftAI download – Free voice AI that can change personalities

Download ShiftAI free. A voice AI with 9 wild personalities including mean, philosophical, and tung tung tung sahur.

favicon
shiftaidownload.xyz

UXRay: I Built an AI That Roasts Your UI Like a Senior Designer Would

This is a submission for the Gemma 4 Challenge: Build with Gemma 4

What I Built

UXRay — drop a screenshot or paste a URL, get a full UX audit in seconds.

Most designers and developers ship UIs without a systematic critique. Hiring a UX consultant is expensive. Running a full user study takes weeks. UXRay closes that gap: it gives you the same structured, heuristic-based analysis a senior UX professional would produce — instantly, locally, and for free.

You give UXRay a UI (file upload or live URL) and it returns:

  • Overall UX score (0–100)
  • Cognitive load analysis — is the interface overwhelming users?
  • Trust score — what signals build or erode credibility?
  • Friction points — specific elements causing drop-off, each mapped to a Nielsen heuristic and rated critical / warning / info
  • Prioritized recommendations — actionable fixes sorted by urgency with effort and impact ratings
  • Accessibility flags — WCAG 2.1 violations visible in the screenshot
  • Layout analysis — fold content, visual hierarchy strength, whitespace quality, and scan pattern (Z vs F)

The analysis is grounded in established UX theory: Nielsen’s 10 Usability Heuristics, Gestalt principles, Fogg’s trust heuristics, Sweller’s cognitive load theory, and WCAG 2.1. Every friction point cites the exact heuristic it violates so you know why something is a problem, not just that it is.

Stack: Next.js 16 (App Router, TypeScript) · Tailwind v4 · Framer Motion · Gemma 4 E4B via Ollama · Playwright microservice for URL screenshots · Zod for structured output validation

Demo

Live test: I pointed UXRay at dev.to. It captured a full-page screenshot, ran the Gemma 4 analysis, and returned a structured result — 85 overall score, 3 friction points, 3 prioritized recommendations — in about 56 seconds on CPU, no GPU required.

Code

UXRay — AI-Powered UX Analysis

X-ray your interface through AI. Powered by Gemma 4 E4B.

UXRay analyzes any UI screenshot like a behavioral psychologist — detecting cognitive load, trust signals, friction points, and actionable redesign recommendations. It uses Gemma 4’s native multimodal vision to see the interface directly, not just process text descriptions.

Built for the Google Gemma 2026 Hackathon on dev.to.

Demo

Upload a screenshot or paste a URL → Gemma 4 analyzes it → structured UX critique appears:

  • Overall UX Score (0–100)
  • Cognitive Load gauge with specific issues
  • Trust Score with positive/negative signals
  • Friction Points with heuristic references (Nielsen, Gestalt, WCAG)
  • Recommendations sorted by priority with effort/impact ratings
  • Accessibility Flags and Layout Analysis

Prerequisites

  1. Ollama installed and running:

    brew install ollama
    brew services start ollama
  2. Gemma 4 E4B pulled:

    ollama pull gemma4:e4b
  3. Node.js 18+

Setup

# Clone the repo
git clone <repo-url>
cd uxray
# Install

…

The two key pieces of the pipeline:

1. Gemma 4 client (web/lib/gemma.ts)

Sends the screenshot as a raw base64 image to Ollama’s /api/generate endpoint with format: "json" enforced, streams the NDJSON response token-by-token, and validates the output against a strict Zod schema. If JSON parsing fails on the first pass, it automatically retries at a lower temperature (0.1) to coax a clean response.

const response = await fetch(`${OLLAMA_BASE_URL}/api/generate`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    model: "gemma4:e4b",
    prompt: SYSTEM_PROMPT + "nn" + USER_PROMPT,
    images: [base64Image],   // raw base64, no data URI prefix
    format: "json",          // enforces valid JSON output
    stream: true,
    options: {
      temperature: 0.3,
      num_ctx: 8192,
    },
  }),
});

2. Playwright screenshot service (playwright-service/server.js)

A small Express server that accepts a URL, spins up Chromium, captures a full-page screenshot, and returns it as base64. This lets UXRay analyze any live site without leaving the local pipeline.

To run it yourself:

# Pull the model first
ollama pull gemma4:e4b

# Start both services (Next.js on :3000, Playwright on :3001)
npm install && npm run dev

How I Used Gemma 4

I chose Gemma 4 E4B (the 4-billion-parameter multimodal variant) for three reasons:

1. Multimodal vision is load-bearing, not decorative

UXRay’s entire value proposition requires seeing the UI. The model has to identify specific elements — button labels, color contrast, spacing, typography — and reason about them in relation to UX principles. Gemma 4’s vision capability handles this natively. There’s no separate OCR step, no layout parsing pipeline, no element segmentation — the model just looks at the screenshot and reasons.

2. E4B runs on CPU in a reasonable time

The 4B parameter count was a deliberate choice. I wanted UXRay to work on a developer’s laptop without requiring a GPU. At ~56 seconds for a full audit on CPU, E4B hits the sweet spot: thorough enough to produce genuinely useful output, fast enough to feel interactive. The 31B Dense model would have been overkill for a local-first tool, and E2B felt too thin for the reasoning depth the structured output requires.

3. JSON mode + structured output validation

Setting format: "json" in the Ollama request pushes Gemma 4 to emit valid JSON directly, which I then validate with a Zod schema. The system prompt defines the exact schema — frictionPoints, cognitiveLoad, trustScore, layoutAnalysis — and the model follows it reliably. This makes the output directly renderable in the UI with zero post-processing.

The system prompt grounds every analysis in specific UX frameworks so the model doesn’t just describe what it sees — it diagnoses why it’s a problem and cites the principle being violated:

You are UXRay, an expert UX analyst with deep knowledge of:
- Nielsen's 10 Usability Heuristics
- Gestalt principles of visual design
- WCAG 2.1 accessibility guidelines
- Cognitive load theory (Sweller)
- Trust and credibility heuristics (Fogg's Persuasive Technology)
- Conversion rate optimization (CRO)

A real friction point from the dev.to analysis looks like this:

{
  "id": "fp-1",
  "location": "Primary CTA button",
  "description": "Button label 'Get started' is generic — users cannot predict what commitment they're making, increasing hesitation at the conversion moment.",
  "severity": "warning",
  "heuristic": "Nielsen #6 — Recognition over recall"
}

Gemma 4’s ability to follow a complex, multi-section JSON schema while simultaneously reasoning about visual design principles across a real screenshot is what makes this whole approach viable. Swap it for a text-only model and UXRay doesn’t exist.

Built with Gemma 4 E4B + Ollama + Next.js 16. Runs fully local — your screenshots never leave your machine.

Wyrly DI: Type-safe Dependency Injection for Modern TypeScript

We have released Wyrly DI, a dependency injection toolkit for TypeScript.

Wyrly DI is designed for modern TypeScript applications that want dependency
injection without relying on reflect-metadata, emitDecoratorMetadata, legacy
decorators, or parameter decorators.

It focuses on:

  • standard decorators
  • type-safe tokens
  • explicit dependency definitions
  • request scopes for web applications
  • inspectable and validatable dependency graphs

In this article, “standard decorators” means TC39 decorators supported by
TypeScript 5.0 and later. This is different from the older
experimentalDecorators model that many existing DI libraries were built
around.

Links

Why another DI toolkit?

Many TypeScript DI libraries use runtime metadata to infer constructor
dependencies.

That can be convenient, but it also means important dependency information can
become hidden behind runtime metadata and decorator behavior.

Wyrly DI takes a more explicit approach.

import { Injectable, token } from "@wyrly/core";

type User = {
  id: string;
  name: string;
};

interface UserRepository {
  findById(id: string): Promise<User | null>;
}

const UserRepositoryToken = token<UserRepository>("UserRepository");

@Injectable({
  deps: [UserRepositoryToken],
  lifetime: "scoped",
})
class GetUserUseCase {
  constructor(private readonly users: UserRepository) {}

  execute(id: string) {
    return this.users.findById(id);
  }
}

By declaring deps, dependencies remain visible to code review, static
analysis, CI, and AI-assisted development tools.

With standard decorators, Wyrly DI does not try to read constructor parameter
types from reflect-metadata. You can still use normal constructor injection,
such as constructor(private readonly users: UserRepository), but the
dependency mapping is declared explicitly with deps.

In the example above, UserRepositoryToken is used because TypeScript
interfaces do not exist at runtime. When the dependency is a class, the class
itself can also be used as a token. In other words, you can use typed tokens for
interfaces and class tokens for classes.

Type-safe tokens

TypeScript interfaces disappear at runtime, so interface-based dependencies need
a runtime token.

Wyrly DI provides typed tokens for that purpose.

const UserRepositoryToken = token<UserRepository>("UserRepository");

const users = scope.resolve(UserRepositoryToken);
// users: UserRepository

The resolved value is inferred as UserRepository, so interface-based
dependencies can still be used in a type-safe way.

Request scopes

Web applications often need dependencies that are different for each request.

For example:

  • current user
  • request
  • response
  • unit of work
  • DataLoader
  • request-scoped cache

Wyrly DI’s web adapters are built around this model:

1 HTTP request = 1 DI scope
1 GraphQL request = 1 DI scope

Request scope is not the only supported lifetime. The core package supports
singleton, scoped, and transient, so you can model application-wide
dependencies, per-request dependencies, and dependencies that should be created
on every resolution.

For example, the Hono adapter creates a scope per request in middleware, then
lets handlers resolve dependencies from that scope.

import { Hono } from "hono";
import { createContainer } from "@wyrly/core";
import { di, getDI, type HonoDIVariables } from "@wyrly/hono";

const app = new Hono<{ Variables: HonoDIVariables }>();
const container = createContainer();

app.use(di(container));

app.get("/users/:id", async (c) => {
  const scope = getDI(c);
  const usecase = scope.resolve(GetUserUseCase);

  return c.json(await usecase.execute(c.req.param("id")));
});

Inspecting and validating the dependency graph

Wyrly DI can inspect and validate the dependency graph.

const graph = container.inspect();

const result = container.validate();

For example, validation can detect dangerous lifetime relationships, such as a
singleton depending on a scoped dependency.

singleton -> scoped dependency

That kind of relationship can cause subtle bugs, such as request-specific data
being held by a singleton.

You can also put graph validation in a test and fail CI when the composition
root becomes invalid.

import { assertEquals } from "@std/assert";

Deno.test("DI graph is valid", () => {
  const result = container.validate();

  assertEquals(
    result.ok,
    true,
    result.issues.map((issue) => issue.message).join("n"),
  );
});

This may be unnecessary for very small applications.

But in applications that care about boundaries, such as DDD or Clean
Architecture style codebases, dependency problems become more expensive to fix
when they are discovered late.

For example:

  • a singleton service accidentally depends on a request-scoped CurrentUser
  • an application use case becomes too coupled to infrastructure details
  • a composition root grows without anyone noticing an invalid dependency
    relationship

Validating the graph in CI helps catch those structural problems before they
become normal parts of the codebase.

Published packages

The following packages are available:

@wyrly/core
@wyrly/next
@wyrly/express
@wyrly/hono
@wyrly/fresh
@wyrly/graphql

Use JSR for Deno:

deno add jsr:@wyrly/core

Use npm for Node.js and Bun:

npm install @wyrly/core

Install adapters as needed:

npm install @wyrly/hono
npm install @wyrly/next
npm install @wyrly/graphql

When Wyrly DI may be a good fit

Wyrly DI may fit your project if you want to:

  • avoid reflect-metadata
  • use DI with TypeScript standard decorators
  • make a composition root explicit in DDD or Clean Architecture
  • use request scopes with Next.js, Hono, Express, or GraphQL
  • make the dependency graph visible in review and CI

When another tool may be a better fit

Another tool may be a better fit if you want:

  • a full-stack framework with controllers, modules, authentication, and
    configuration management
  • automatic scanning and registration as the primary workflow
  • parameter decorator-based injection
  • compatibility with an existing app that deeply depends on reflect-metadata

Wyrly DI intentionally separates a small explicit DI core from framework
adapters.

Examples

The repository includes runnable examples for:

  • DDD composition root
  • Hono API
  • Express API
  • Next.js App Router
  • GraphQL request scope
  • DataLoader pattern
  • dependency graph validation

See the examples here:

github.com/valid-lab/wyrly/tree/main/examples

Closing

Wyrly DI is a toolkit for making TypeScript dependency injection explicit,
analyzable, and practical for request-scoped web applications.

The design principles are:

  • explicit dependency definitions over implicit auto-resolution
  • inspectable structure over hidden behavior
  • standard decorators over legacy decorators
  • type-safe tokens over string-only tokens
  • composition roots over automatic scanning

If that sounds useful for your project, start with @wyrly/core and the example
closest to your framework.

The contract is the interface: agent-driven Steampipe Stave in one command

Consider a typical cloud-security tool’s onboarding flow. A customer installs the tool. The tool’s collector tries to authenticate to AWS, fails because the role isn’t there yet, the customer follows three pages of setup docs, the role gets created, the collector authenticates, the collector runs, the collector finds nothing because the tool only knows about S3 and IAM and the customer’s workload is on EKS. End of week one.

We don’t ship a collector. Stave evaluates obs.v0.1 JSON snapshots — whatever produces them. That decision sounds extreme until you’ve watched the same “the collector doesn’t see our environment” conversation play out three times. So instead of a collector, Stave ships a contract: per-asset JSON Schemas, per-asset Steampipe→Stave column mappings, and one command (stave contract show) that emits everything an agent needs to author its own ingest. The customer’s preferred source (Steampipe, AWS Config, Terraform state, an internal inventory API) plugs in by satisfying the contract.

This post walks through the steps that closes the pipeline.

What the customer sees

$ stave contract show --asset-type aws_s3_bucket
Contract: aws_s3_bucket
Schema:   schemas/observation/v1/asset-types/aws_s3_bucket.schema.json
Controls: 102 | Chains: 15

Property paths (catalog reads these — sorted by chain unlock, then control unlock):

  PATH                                                          CONTROLS  CHAINS  SEVERITY  NOTE
  ────                                                          ────────  ──────  ────────  ────
  storage.kind                                                  91        15      critical
  storage.tags.data-classification                              14        2       critical  intent
  storage.access.public_read                                    8         2       critical
  storage.controls.public_access_fully_blocked                  3         1       critical
  ...

Steampipe mapping: contracts/steampipe/aws_s3_bucket.yaml

That output names everything the customer’s ingest agent needs:

  • The schema — the JSON Schema the agent’s output must satisfy
  • The property paths — what fields the catalog actually reads on this asset type, ranked by how many controls and chains they unlock
  • The mapping — a ready-to-run YAML telling the agent which Steampipe column maps to which Stave property path

For the 17 most catalog-impactful asset types, the mapping is committed. For the rest, the customer’s agent has the schema; it can author its own.

The YAML mapping format

The Steampipe→Stave mapping is one ordered list of operations per asset type. Four operation kinds cover every transform shape:

  • field — direct column → property mapping with optional coerce/default
  • static — a fixed value (e.g. properties.storage.kind: bucket)
  • extract — pull a nested JSON value from a JSON-shaped column
  • computed — derive from already-set property paths (all / any reduction)

Operations run in YAML order; later ops can read paths written by earlier ones. The first mapping we wrote — contracts/steampipe/aws_s3_bucket.yaml — replaced a Python function with a declarative file. The loader changes are 100 lines; the resulting observation is byte-identical to what the imperative function produced.

operations:
  - kind: static
    path: properties.storage.kind
    value: bucket

  - kind: field
    path: properties.storage.tags
    column: tags
    default: {}
    type: dict

  - kind: extract
    path: properties.storage.encryption.algorithm
    column: server_side_encryption_configuration
    json_path: "Rules.0.ApplyServerSideEncryptionByDefault.SSEAlgorithm"
    key_variants:
      Rules: rules
      SSEAlgorithm: sse_algorithm
    default: "none"

  - kind: computed
    path: properties.storage.controls.public_access_fully_blocked
    op: all
    inputs:
      - properties.storage.controls.public_access_block.block_public_acls
      - properties.storage.controls.public_access_block.block_public_policy
      - properties.storage.controls.public_access_block.ignore_public_acls
      - properties.storage.controls.public_access_block.restrict_public_buckets

The format is the contract. Any agent in any language can parse the YAML and produce conforming observations.

Per-asset JSON Schemas

The catalog ships 3,957 controls; together they declare applicable_asset_types for 109 distinct asset types. To validate that a mapping’s target paths are real, we needed a JSON Schema per asset type. Hand-authoring 109 schemas is a Tuesday lost; the schema generator already existed (it walks every control’s predicate AST and infers the property paths + types), but defaulted to the top-3 most-used types.

go run ./internal/tools/genassetschemas/... -top 200
make sync-schemas

Output: 109 per-asset schemas under schemas/observation/v1/asset-types/. Every level is additionalProperties: true — the schemas are discoverability artifacts, not restrictive gates. A schema that lists one property (security_hub.enabled on aws_securityhub_account, for example) tells an agent “this asset type matters to the catalog; here is the one property to populate.” Thin schemas are still useful.

Ten hand-authored mappings

The next 10 asset types by control coverage — aws_iam_role, aws_lambda_function, aws_cognito_user_pool, aws_cloudtrail_trail, aws_kms_key, aws_ec2_instance, aws_sqs_queue, aws_iam_user, aws_opensearch_domain, aws_stepfunctions_state_machine — got hand-authored mappings. They served two purposes: actual coverage for the most-asked-for types, and a ground-truth corpus to validate Iter 5’s auto-generator against.

Every mapping carries a derived_properties: block listing the catalog-read properties that cannot come from a single Steampipe column. Example from aws_iam_role.yaml:

derived_properties:
  - path: properties.identity.role.cross_account_trust_without_external_id
    source: "Parse trust_policy — detect external Account in Principal without sts:ExternalId condition"
  - path: properties.identity.permission_categories.has_incompatible_categories
    source: Policy analysis against controldata/taxonomy/permission_categories.yaml
  - path: properties.identity.access_advisor.available
    source: iam:GenerateServiceLastAccessedDetails + iam:GetServiceLastAccessedDetails (separate API call per role)

That block is the agent’s TODO list. Silently producing an observation without those derived properties is the failure mode the derived_properties: section prevents — Stave’s controls don’t see the property, the catalog finds nothing wrong, the breach happens anyway.

The Contract Show Command

The three sources — schema, predicate index, mapping file — already existed. Joining them required three separate file reads. The new command joins them once:

stave contract show --asset-type aws_iam_role --format json
{
  "asset_type": "aws_iam_role",
  "has_schema": true,
  "schema_path": "schemas/observation/v1/asset-types/aws_iam_role.schema.json",
  "controls_count": 198,
  "chains_count": 38,
  "property_paths": [
    {
      "path": "properties.identity.kind",
      "controls_count": 196,
      "chains_count": 35,
      "max_severity": "critical",
      "is_intent_property": false
    },
    ...
  ],
  "steampipe_mapping": "contracts/steampipe/aws_iam_role.yaml"
}

Or:

stave contract show --list
Asset types with controls: 109 (schema: 109, steampipe mapping: 17)

  TYPE                              SCHEMA  CONTROLS  CHAINS  MAPPING
  ────                              ──────  ────────  ──────  ───────
  aws_iam_role                      yes     198       38      steampipe
  aws_s3_bucket                     yes     102       15      steampipe
  aws_lambda_function               yes     169       12      steampipe
  aws_bedrock_agent                 yes     24        5       -
  ...

The implementation reuses everything already in the codebase: compose.LoadControlsFrom, compose.LoadChainDefinitions, predindex.Build (the same index the stave gaps command uses), and a 50-line helper in internal/contracts/schema/load.go to access the embedded per-asset schemas. The command is ~330 lines; nothing is new data — it’s projection over existing data.

Auto-generator

The remaining ~98 asset types could be hand-authored or auto-generated. We tried auto. The generator joins the cached Steampipe column catalog with each per-asset schema’s property paths, applies a four-rule matching priority (per-asset overrides, schema-path lookup with multi-token scoring, tags convention, fallback to properties.<ns>.<col>), and emits a YAML in the same operations-list format Iter 1 established.

make gen-steampipe-mappings           # generate, skip existing
make gen-steampipe-mappings-validate  # measure accuracy

Validation runs the generator against the 11 hand-authored YAMLs (Iter 1 + Iter 3) and compares the auto-generated (column, path) tuples against the ground truth:

Overall: 149/177 = 84% accuracy across 17 type(s)

84% — past the 80% target. The remaining 16% are the multi-target JSON-path extracts the brief flagged as inherently manual (one column → two property paths is not something a name-similarity heuristic can synthesise). Auto-generated YAMLs carry _auto_generated: true + _review_required: N + _unmatched_paths: [...] so the reviewer’s surface is bounded.

The detailed story of the heuristic — and how it went from 8% accuracy on the first pass to 84% on the fourth — is its own post. The point here is what’s committed: 17 total mappings (11 hand-authored, 6 auto-generated), every one of them an artifact a customer’s agent can read in any language.

Who owns contract sits where it does

The architecture choice that makes this work: extractors are client-owned. Stave does not ship a collector. The contracts/steampipe/ directory contains instructions, not code. An agent reads the schema and the mapping; the agent produces the observation; Stave evaluates the observation. The collector boundary is a file, not a process.

This decision has been in our architecture docs since the project started, but until now there was no single command that surfaced the contract to an agent. An agent that wanted to author a Steampipe ingest for a new asset type had to:

  1. Find the per-asset schema (one of several embedded directories)
  2. Decide what property paths to populate (no canonical list — derive from controls)
  3. Map Steampipe columns to those paths (no template — invent it)

The agent runs one command and gets all three. The agent runs make gen-steampipe-mappings and gets a starting-point YAML it can refine. The integration is a lot easier.

What stayed out of Stave

Nothing in the Stave Go binary changed across the five iterations except the new cmd/contract/ directory (one file, ~330 LOC). The agent infrastructure is:

  • examples/agents/stave_transform.py — reference loader (Python)
  • contracts/steampipe/*.yaml — 17 mappings (committed)
  • scripts/gen-steampipe-mappings.py — auto-generator (Python, ~280 LOC)
  • scripts/steampipe-columns.json — cached column catalog (refreshable from a live Steampipe install)

The deterministic policy engine is unchanged. The contract evolves; the engine doesn’t.

The Generic Pipeline Shape

Replace Steampipe with any external data source — AWS Config, Terraform state, your internal inventory, Salesforce, OpenAPI specs — and the pipeline shape is the same:

  1. Define the canonical target contract. For Stave it’s obs.v0.1 JSON with per-asset-type sub-schemas. For your tool, it’s whatever shape your engine reads.

  2. Author one mapping per source per asset type. YAML is fine. Operations list with field/static/extract/computed semantics covers most transform shapes.

  3. Ship a discovery command. One CLI that joins the schema + the path list + the mapping into a single agent-readable output. The agent stops needing your team’s docs.

  4. Auto-generate the boring half. Most column→path mappings are name-similarity. The exceptions are rare enough to hand-author. Use the hand-authored set as a ground-truth corpus to measure your generator’s accuracy.

  5. Mark uncertainty explicitly. _review_required, _unmatched_paths, derived_properties:. Silent gaps are worse than loud ones.

Five points, one functioning pipeline. The customer who needed three pages of collector setup now needs make gen-steampipe-mappings and an agent that can read a YAML.