AI Is Becoming Ambient: My Biggest Takeaway From Google I/O 2026

This is a submission for the Google I/O Writing Challenge

I spent some time watching the Google I/O 2026 “What’s New in AI” session, and one small detail personally stood out to me.

One of the presenters was Paige Bailey. Years ago, I used to watch her GenAI workshops on Kaggle while learning AI on my own. Seeing her present at Google I/O felt like a reminder of how quickly this space has evolved.

But beyond the announcements themselves, I think the biggest message from Google I/O was this:

AI is no longer being treated as a standalone product.
It’s becoming ambient infrastructure.

AI is becoming ambient

What stood out to me most was how deeply AI is now integrated across Google’s ecosystem.

Search, Workspace, Android, Chrome, YouTube, developer tools — AI is quietly being embedded everywhere.

Not as a separate chatbot tab, but as an invisible layer sitting behind the products people already use every day.

AI is becoming ambient.

Multimodal AI is becoming the default

Another thing that stood out was how natural multimodal interaction now feels.

Text, voice, images, video, screen understanding, and real-time context are all starting to blend together into a single experience.

Instead of switching between different tools, the AI increasingly understands multiple forms of input at once.

That feels like a major UX shift.

The Shift Toward “Agentic” AI

Another word that repeatedly appeared during the presentations was “agentic.”

The industry is clearly moving away from AI that simply answers questions toward AI that can actually perform tasks, make decisions, and operate across tools.

That shift feels important.

The conversation is no longer:

“Can AI chat with you?”

Now it’s:

“Can AI do things for you?”

Google Search is long gone

One thing became very obvious during the demos:

The old version of Google Search is quietly disappearing.

Typing keywords and manually digging through links is slowly being replaced by AI-generated summaries, conversational search, and agents that help organize information for you.

Whether that change is good or bad is still open for debate, especially for creators and websites across the internet.

But the direction feels clear.

Comparing Google with OpenAI and Anthropic

What’s interesting is how differently each company seems to be approaching AI.

Google still feels strongest in consumer AI momentum and product experience.

It has built a strong reputation around reasoning quality and safety-focused systems.

But it may have the biggest advantage in ecosystem integration.

Google already owns platforms used by billions of people daily:

  • Search
  • Android
  • YouTube
  • Chrome
  • Workspace

That means AI can spread across everyday life extremely fast.

And after watching Google I/O, it feels like that’s exactly where things are heading.

Screenshot-Driven Vibe Coding: Why Your AI Workflow Needs a Glossary Step

The other day I came across a Claude Code todo list that looked like this:

✓ Read and identify all screenshots
✓ Categorize screenshots into 4 groups
◼ Create folders and move/rename screenshots
☐ Write business requirements doc per screenshot

Four steps. Clean. The kind of plan a vibe coder would screenshot and post on X with the caption “AI is unreal right now.”

And honestly — it is a good plan. Better than 90% of what I see in the wild. But it’s also one tiny step away from producing 30 beautifully written documents that quietly contradict each other.

Let me walk you through why this strategy works, where it breaks, and the one step I’d add before you let your AI write a single requirement.

The strategy, decoded

Before we critique anything, let’s give credit where it’s due. This workflow has three things going for it that most vibe coding sessions don’t.

It uses screenshots as the source of truth. Most people start vibe coding from a vague idea in their head — “build me a project management app like ClickUp but cheaper”. The AI has nothing concrete to anchor on, so it hallucinates a generic CRUD app, and you spend the next three days correcting it. Screenshots flip this. They’re concrete. They show real states, real data, real edge cases. An AI looking at a screenshot can’t drift as far as one listening to a wish.

It categorizes before it documents. The “4 groups” step is the unsung hero here. Without grouping, you end up with thirty disconnected requirements docs that don’t share vocabulary. Grouping forces pattern recognition — these eight screens are all CRUD on the same entity, these five are reporting, these three are settings. That clustering naturally maps to modules or bounded contexts later, which is exactly what you want when you start translating documents into code.

It produces atomic, reviewable artifacts. One BRD per screenshot. Small, self-contained, easy to diff, easy to hand to Claude Code with a prompt like “generate the Livewire component for this spec.” You can iterate on one BRD without rewriting the whole project.

This is genuinely a good foundation. If you stopped reading here and ran with it, you’d be ahead of most teams.

But we’re not stopping here.

Failure mode #1: BRDs without a shared vocabulary

Here’s what happens when you skip from categorization straight to writing BRDs.

Screenshot 03 shows a customer list. The AI writes: “The system shall display a paginated list of **Customers.”

Screenshot 11 shows a customer detail page. The AI writes: “The **Member* profile shall include…”*

Screenshot 19 shows the same person logging in. The AI writes: “Upon authentication, the **User* is redirected…”*

Screenshot 24 is a B2B contact view. The AI writes: “Each **Account* has a primary contact…”*

Customer. Member. User. Account. Four words. Same entity. Different document. Different author session. Different vibes.

Now imagine you feed all 30 BRDs to Claude Code and ask it to generate migrations. Watch what happens.

You get a customers table, a members table, a users table (probably from Laravel’s default), and an accounts table. None of them reference each other. The Eloquent relationships are guesswork. Half your Actions take Customer $customer, the other half take User $user, and your form requests use both interchangeably.

This is the most common failure I see in screenshot-driven workflows, and it never shows up in the demo. It shows up in week three, when you realize half your codebase is talking past itself.

The fix is a vocabulary extraction step. Before any BRD gets written, you produce a glossary.md that looks something like this:

| Term     | Definition                              | Aliases                  |
|----------|-----------------------------------------|--------------------------|
| Customer | An organisation that purchases services | Account, Client          |
| Member   | An individual user within a Customer    | User, Contact, Staff     |
| Order    | A purchase transaction                  | Sale, Invoice (informal) |

Now when the AI writes BRD-019, it consults the glossary, sees that “Member” and “User” are aliases for the same concept, and picks one canonical term. Suddenly your thirty documents speak the same language.

Failure mode #2: Screen-driven design is not system design

Screenshots tell you what the user sees. They don’t tell you what the system does.

Look at any login screen. The screenshot shows a form with email, password, and a button. The BRD writes itself: “User enters credentials, system authenticates, user is redirected to dashboard.”

What the screenshot doesn’t show:

  • The rate limiter that locks the account after five failed attempts
  • The audit log entry that records every login attempt, successful or not
  • The webhook that fires to your CRM when a new user logs in for the first time this month
  • The background job that recomputes the user’s permission cache
  • The SSO redirect path that bypasses the password field entirely
  • The API endpoint that mobile clients use, which doesn’t render this form at all

None of that is in the picture. All of it is in the system.

If you’re building a SaaS — and especially if you’re in the kind of architecture I tend to work in, where you’ve got a hub-and-spoke pattern with things like identity providers, API gateways, and webhook routers — half your architecture lives behind the UI. A pure screenshot-driven workflow will miss it entirely.

The fix is to add a step per group (not per screenshot) where you explicitly list the non-UI concerns:

  • Background jobs and scheduled tasks
  • Webhooks emitted and consumed
  • Permission matrix (who can do what)
  • Audit and observability requirements
  • API contracts for non-UI consumers
  • Integration points with other modules

This doesn’t need to be exhaustive on the first pass. It just needs to exist so you remember to look behind the curtain.

Failure mode #3: Losing the trail

The original workflow says “create folders and move/rename screenshots.” Good — but rename them to what?

If your screenshots become screenshot_001.png, screenshot_002.png, you’ve lost the only useful thing you could have encoded: traceability.

Three months later, when you’re debugging a Livewire component and you want to know “what was the original requirement for this?”, you want to be able to trace:

Code:        app/Livewire/CustomerList.php
   ↑ generated from
BRD:         docs/brd/g01-s03-customer-list.md
   ↑ extracted from
Screenshot:  screenshots/g01-customers/s03-list-view.png

The naming convention does this work for you, silently. Something like g01-s03-customer-list.png tells you immediately: group 1, screen 3, the customer list. The BRD inherits the same ID. The generated code references it in a docblock. Now you have a chain of custody from pixel to production.

This costs you nothing at rename time. It costs you everything if you skip it.

Failure mode #4: Inconsistent BRD structure

If you let the AI freestyle each BRD, you’ll get thirty documents with thirty slightly different structures. One has a “User Stories” section, another has “Use Cases”, a third has “Functional Requirements”, a fourth jumps straight into a numbered list of behaviours.

This isn’t a vibe problem, it’s a parsing problem. When you later want to feed these BRDs into Claude Code to generate code, the AI does much better with predictable structure. Same headings, same order, same vocabulary for sections.

Define the template once, before the loop:

# {Screen ID} — {Screen Name}

## Purpose
What this screen exists to do, in one sentence.

## Actors & Permissions
Who can access this screen, and what role gates apply.

## Entities Referenced
List of glossary terms this screen interacts with.

## User Actions
For each action: trigger, validation rules, side effects, success state, failure state.

## States
Empty state, loading state, error state, success state.

## Non-Functional Notes
Performance expectations, accessibility, audit requirements.

## Open Questions
Things the screenshot doesn't tell us and we need to confirm.

Now every BRD looks the same. Claude Code knows exactly where to look for “what validation rules apply to the Save button.” Your reviewers know exactly where to add comments. Your future self thanks you.

The refined workflow

Putting it all together, here’s what I’d actually run:

1. ✓ Read and identify all screenshots
2. ✓ Categorize into groups (by feature area, not screen type)
3. ◼ Create folders and rename with traceable IDs (g{NN}-s{NN}-{slug})
4. NEW: Extract shared entities, enums, and permissions → glossary.md
5. NEW: Define the BRD template once
6. ☐ Write BRD per screenshot, referencing the glossary
7. NEW: Per group, document non-UI concerns (jobs, webhooks, audits, APIs)
8. NEW: Synthesise — produce the domain model, module boundaries, Action inventory

Steps 4, 5, 7, and 8 are the ones that turn a demo into a system.

Step 8 is where this becomes powerful, by the way. Once you have a glossary, BRDs in consistent format, and non-UI concerns mapped, you can feed all of it to Claude Code with a prompt like:

Given this glossary and these BRDs, generate the Eloquent models with relationships, Form Requests, invokable Actions, and Pest tests for the Customers module.

And the output will actually fit. The naming will be consistent. The relationships will make sense. The tests will reference the right entities. Because you did the upstream work of giving the AI a coherent context to operate in.

The lesson nobody puts on the screenshot

If you take one thing from this, take this:

AI is excellent at extraction. Humans must still define the schema of what’s extracted.

The screenshot-to-BRD workflow looks like the AI is doing all the work. It isn’t. It’s doing the typing. The thinking — the categorization, the vocabulary, the template, the boundaries between modules — that’s still yours. Skipping those steps doesn’t speed you up. It just defers the cost to a later point in the project where it’s ten times more expensive to fix.

Vibe coding isn’t about letting the AI decide. It’s about giving the AI a structured enough world that its decisions stop being random.

Add the glossary step. Define the template. Document what the screenshot doesn’t show. Encode the trail in your filenames.

Then let it rip.

If you’ve tried screenshot-driven workflows on your own projects, I’d love to hear which failure modes you’ve hit and how you worked around them. Drop a comment.

How to build a production RAG pipeline in Python (without a vector database)

Everyone reaching for a vector database when building RAG is solving the wrong problem first. For most domain-specific corpora — technical documentation, company knowledge bases, article archives — BM25 retrieval is competitive with semantic search, costs a fraction of the compute, and is dramatically simpler to operate. This tutorial shows you how to build a full RAG pipeline using Meilisearch as the retrieval backend, stream responses from an LLM API, and evaluate hit rate without a single embedding model.

Why RAG, and why not a vector database

Retrieval-Augmented Generation solves a fundamental problem: LLMs have a knowledge cutoff and a finite context window. You want answers grounded in your documents, not hallucinated from pre-training.

The standard advice is to use a vector database (Pinecone, Weaviate, Chroma). Vector search is powerful for open-domain retrieval where semantic similarity matters. But on a domain-specific corpus with consistent terminology — think a cybersecurity knowledge base or a medical reference — BM25 with typo tolerance typically achieves 85–95% of the recall you’d get from embeddings, with zero GPU cost, sub-10ms latency, and no embedding pipeline to maintain.

Meilisearch gives you BM25 out of the box, plus typo tolerance, faceted filtering, and a simple REST API. It’s what I use to power the search across 1,600+ articles at AYI NEDJIMI Consultants.

Setup

pip install meilisearch openai httpx

Run Meilisearch locally:

docker run -d -p 7700:7700 getmeili/meilisearch:latest

Step 1: Index your documents

Your documents need an id, searchable content, and any filter attributes you want to use at query time.

import meilisearch
import hashlib
import json

MEILI_URL = "http://127.0.0.1:7700"
MEILI_KEY = "your_master_key"  # or "" for local dev
INDEX_NAME = "knowledge_base"

client = meilisearch.Client(MEILI_URL, MEILI_KEY)

def get_or_create_index():
    try:
        index = client.get_index(INDEX_NAME)
    except meilisearch.errors.MeilisearchApiError:
        task = client.create_index(INDEX_NAME, {"primaryKey": "id"})
        client.wait_for_task(task.task_uid)
        index = client.get_index(INDEX_NAME)

    # Configure searchable attributes and filters
    index.update_settings({
        "searchableAttributes": ["title", "content", "tags"],
        "filterableAttributes": ["category", "doc_type"],
        "rankingRules": [
            "words", "typo", "proximity", "attribute", "sort", "exactness"
        ],
        "typoTolerance": {
            "enabled": True,
            "minWordSizeForTypos": {"oneTypo": 4, "twoTypos": 8}
        }
    })
    return index

def index_documents(documents: list[dict]):
    """
    Each document: {"id": str, "title": str, "content": str,
                    "tags": list[str], "category": str, "doc_type": str}
    """
    index = get_or_create_index()

    # Add stable IDs if not present
    for doc in documents:
        if "id" not in doc:
            doc["id"] = hashlib.sha256(doc["content"].encode()).hexdigest()[:16]

    task = index.add_documents(documents, primary_key="id")
    client.wait_for_task(task.task_uid)
    print(f"Indexed {len(documents)} documents.")

# Example: load from a JSONL file
def load_and_index(filepath: str):
    docs = []
    with open(filepath) as f:
        for line in f:
            docs.append(json.loads(line.strip()))
    index_documents(docs)

Step 2: Retrieve top-k documents

def retrieve(query: str, top_k: int = 5, filters: str = "") -> list[dict]:
    """
    Returns top_k documents matching the query.
    filters example: "category = 'security' AND doc_type = 'guide'"
    """
    index = client.get_index(INDEX_NAME)

    search_params = {
        "limit": top_k,
        "attributesToRetrieve": ["id", "title", "content", "category"],
        "attributesToHighlight": ["content"],
        "highlightPreTag": "**",
        "highlightPostTag": "**",
    }

    if filters:
        search_params["filter"] = filters

    results = index.search(query, search_params)
    return results["hits"]

Step 3: Construct the prompt

The prompt structure is critical. You want the model to be explicitly grounded — it should cite only what’s in the retrieved chunks, not hallucinate.

def build_prompt(query: str, retrieved_docs: list[dict]) -> list[dict]:
    context_blocks = []
    for i, doc in enumerate(retrieved_docs, 1):
        context_blocks.append(
            f"[Source {i}] {doc['title']}n{doc['content'][:1200]}"
        )

    context = "nn---nn".join(context_blocks)

    system_prompt = (
        "You are a technical assistant. Answer the user's question using ONLY "
        "the provided sources. If the answer is not in the sources, say so explicitly. "
        "Cite sources by number, e.g. [Source 1]."
    )

    user_message = f"""Sources:
{context}

---

Question: {query}"""

    return [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": user_message},
    ]

Step 4: Stream the LLM response

Never buffer the full response before sending it to the user. Streaming is essential for UX on long answers.

from openai import OpenAI  # generic llm_client — swap for any compatible SDK

llm_client = OpenAI(
    api_key="your_api_key",
    base_url="https://api.your-llm-provider.com/v1",  # adjust per provider
)

def rag_stream(query: str, category_filter: str = ""):
    """Generator that yields text chunks as they arrive from the LLM."""
    filters = f"category = '{category_filter}'" if category_filter else ""
    docs = retrieve(query, top_k=5, filters=filters)

    if not docs:
        yield "No relevant documents found in the knowledge base."
        return

    messages = build_prompt(query, docs)

    stream = llm_client.chat.completions.create(
        model="gpt-4o-mini",  # or your preferred model
        messages=messages,
        stream=True,
        temperature=0.2,  # lower temp for factual retrieval tasks
        max_tokens=800,
    )

    for chunk in stream:
        delta = chunk.choices[0].delta
        if delta.content:
            yield delta.content

Step 5: Wire it together — a minimal CLI

import sys

def main():
    query = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else input("Query: ")
    print(f"nQuery: {query}n{'='*60}n")

    for token in rag_stream(query):
        print(token, end="", flush=True)

    print("n")

if __name__ == "__main__":
    main()

Usage:

python rag.py "What are the key requirements of NIS 2 for SMEs?"

Step 6: Evaluate hit rate

Before deploying, measure whether your retrieval is actually finding the right documents. You need a small golden dataset: query → expected document ID.

def evaluate_hit_rate(golden_set: list[dict], top_k: int = 5) -> float:
    """
    golden_set: [{"query": "...", "expected_id": "doc_id"}, ...]
    Returns hit rate @ top_k.
    """
    hits = 0
    for item in golden_set:
        results = retrieve(item["query"], top_k=top_k)
        retrieved_ids = {r["id"] for r in results}
        if item["expected_id"] in retrieved_ids:
            hits += 1

    hit_rate = hits / len(golden_set)
    print(f"Hit rate @{top_k}: {hit_rate:.2%} ({hits}/{len(golden_set)})")
    return hit_rate

# Example usage
golden = [
    {"query": "NIS 2 SME requirements", "expected_id": "nis2-guide-001"},
    {"query": "ISO 27001 certification steps", "expected_id": "iso27001-checklist"},
    {"query": "penetration testing methodology", "expected_id": "pentest-guide-002"},
]

evaluate_hit_rate(golden, top_k=5)

On a 1,600-article cybersecurity corpus, this setup achieves roughly 91% hit rate at k=5 — without a single embedding model call.

Production considerations

Chunking strategy: For long documents, chunk at 512–800 tokens with 10% overlap. Store doc_id and chunk_index so you can reconstruct the full document if needed.

Re-ranking: If your hit rate plateaus below 85%, add a lightweight cross-encoder re-ranker as a second stage. cross-encoder/ms-marco-MiniLM-L-6-v2 from Sentence Transformers works locally and adds ~30ms latency.

Context window budget: At 5 docs × 1,200 chars, you’re using roughly 1,500 tokens of context. Adjust top_k and content truncation to stay within your model’s window while leaving room for the answer.

Caching: Cache retrieval results for identical queries with a TTL of 5–15 minutes using Redis or even a simple in-memory dict. LLM call results can be cached longer for factual queries.

This pipeline — retrieval with Meilisearch, prompt construction, streaming output — is what I run in production. No embedding pipeline, no vector database operational overhead. For domain-specific retrieval, BM25 is frequently the pragmatic choice. Reach for semantic search when your query vocabulary genuinely diverges from your document vocabulary; otherwise, ship the simpler thing.

Converting iPhone HEVC Video Into a Telegram Video Avatar

The bug that wasted my afternoon

I tried to set a video as my Telegram profile picture. I recorded a short clip on my iPhone, opened Telegram, and picked it as my avatar. Telegram accepted the file, showed a spinner, then quietly fell back to a static frame. No error. No warning. Just a still image where a looping video should have been.

Turns out Telegram has strict rules for video avatars, and iPhone video breaks almost all of them at once. The clip was HEVC, the wrong resolution, too long, and carried an audio track. Telegram does not tell you any of this. It just gives up.

I wanted the actual feature, so I dug into what the spec really needs and wrote a converter. This post is the result.

What Telegram actually requires

A Telegram video avatar is not a normal video upload. The constraints are tight:

  • Codec: H.264 (AVC). HEVC is rejected.
  • Container: MP4 with the moov atom at the front (faststart).
  • Resolution: square. 800×800 is the safe target.
  • Duration: 10 seconds or less.
  • Size: roughly 2MB or less.
  • No audio track.
  • Pixel format: yuv420p. Some players choke on yuv444p or 10-bit.

iPhone footage fails on codec, shape, duration, audio, and often pixel format. A modern iPhone records HEVC in 10-bit at 1080×1920 or wider. Every one of those points needs fixing before Telegram will animate it.

The silent failure is the worst part. You cannot debug a process that refuses to report what went wrong.

Fixing it with ffmpeg

ffmpeg does all the real work here. The interesting step is cropping a vertical phone video into a centered square without squishing faces.

Detecting the crop region

cropdetect scans frames and prints the largest non-black crop rectangle. I run a quick pass to read it:

ffmpeg -i input.mov -vf cropdetect=24:16:0 -frames:v 60 -f null - 2>&1 | 
  grep -o 'crop=[0-9:]*' | tail -1

That prints something like crop=1080:1080:0:420. For a phone clip I usually ignore the detected offset and take a centered square of the shorter side, but cropdetect earns its keep when the video has letterboxing or black bars.

The encode pass

Here is the command that produces a Telegram-ready avatar:

ffmpeg -y -i input.mov 
  -t 10 
  -an 
  -vf "crop='min(iw,ih)':'min(iw,ih)',scale=800:800:flags=lanczos,format=yuv420p" 
  -c:v libx264 -profile:v high -level 4.0 
  -preset slow -crf 26 
  -movflags +faststart 
  -pix_fmt yuv420p 
  output.mp4

Walking through the flags that matter:

  • -t 10 hard-caps duration at 10 seconds.
  • -an drops the audio track. Telegram wants none.
  • crop='min(iw,ih)':'min(iw,ih)' takes the largest centered square that fits.
  • scale=800:800:flags=lanczos resizes to the target. Lanczos keeps edges sharp.
  • format=yuv420p plus -pix_fmt yuv420p force 8-bit 4:2:0, which fixes 10-bit HEVC.
  • -c:v libx264 re-encodes to H.264.
  • -movflags +faststart moves the moov atom to the front so playback can start immediately.
  • -crf 26 is the quality knob. If the file lands above 2MB, I raise CRF a few points and re-run.

One pass, and an iPhone clip becomes a file Telegram will actually loop.

Keeping it under 2MB

CRF 26 handles most 10-second clips, but a busy scene with lots of motion can still overshoot. My loop is simple: encode, check the file size, and if it is over 2MB raise CRF by 4 and try again. Two attempts cover almost everything. For stubborn clips I trim duration before touching quality, since 6 seconds at decent quality beats 10 seconds of mush.

Wiring it into a bot

Running ffmpeg by hand is fine once. I wanted to send a video to a chat and get the avatar back, so I wrapped it in an aiogram 3 handler.

from aiogram import Bot, Dispatcher, F
from aiogram.types import Message, FSInputFile
import asyncio, tempfile, os

dp = Dispatcher()


@dp.message(F.video | F.animation | F.document)
async def handle_video(message: Message, bot: Bot):
    file = message.video or message.animation or message.document
    if not file:
        await message.answer("Send me a video or GIF.")
        return

    with tempfile.TemporaryDirectory() as tmp:
        src = os.path.join(tmp, "in")
        dst = os.path.join(tmp, "out.mp4")
        await bot.download(file, destination=src)

        ok = await convert_to_avatar(src, dst)
        if not ok:
            await message.answer("Could not convert that one. Try a shorter clip.")
            return

        await message.answer_video(
            FSInputFile(dst),
            caption="Ready. Set this as your profile video.",
        )


async def convert_to_avatar(src: str, dst: str) -> bool:
    proc = await asyncio.create_subprocess_exec(
        "ffmpeg", "-y", "-i", src, "-t", "10", "-an",
        "-vf", "crop='min(iw,ih)':'min(iw,ih)',scale=800:800:flags=lanczos,format=yuv420p",
        "-c:v", "libx264", "-preset", "slow", "-crf", "26",
        "-movflags", "+faststart", "-pix_fmt", "yuv420p", dst,
        stdout=asyncio.subprocess.DEVNULL,
        stderr=asyncio.subprocess.DEVNULL,
    )
    await proc.wait()
    return proc.returncode == 0 and os.path.exists(dst)

The F.video | F.animation | F.document filter matters. Telegram delivers the same file as different types depending on whether the user sends a video, a GIF, or a file attachment. Catching all three saves a lot of confused users.

asyncio.create_subprocess_exec keeps the bot responsive while ffmpeg runs. A 10-second clip encodes in a couple of seconds on a small VPS, but blocking the event loop would still freeze every other chat.

Packaging it as a bot anyone can use

I cleaned this up and shipped it as @LiveAvaBot. You send a video or GIF, and it returns an 800×800 H.264 file under 2MB with audio stripped, ready to drop in as a profile video. It handles HEVC from iPhones, vertical clips, oversized files, and GIFs pulled from random websites.

Most of the credit goes to ffmpeg. I just wrote the wrapper that picks sane flags and hides the silent-failure problem behind a single message.

Edge cases and what is next

A few things I learned along the way:

  • Square clips that are already H.264 still need re-encoding for the moov atom and the audio strip. Skipping the convert step because the file looks fine produced more silent failures.
  • Animated WebP and some GIFs decode with a variable frame rate. Adding -r 30 to the encode smooths playback.
  • Very dark footage confuses cropdetect. The centered-square crop is a more reliable default than trusting detection on every clip.
  • 4K HEVC is slow to decode on a small VPS. It works, it just takes longer, and that is the next thing I want to speed up.

The whole project started because Telegram would not tell me why my avatar was a still image. Now it is one message to a bot.

I built this one myself. The bot is @liveavabot. If you try it and a clip fails, I want to hear which clip.

I Built an AI Data Chat Tool in My Portfolio App Using Gemma 4, CrewAI, DuckDB, Supabase Edge Functions & Google Cloud Run 🚀

What If You Could Just… Ask Your Data a Question? 🤔

Most people who need insights from a data file are blocked by one simple thing: they don’t know SQL. Even technically strong users often don’t want to stop, inspect schema manually, write queries, debug syntax, and format results just to answer a quick question like “Which category has the highest revenue?” or “Show me null rates by column.”

This project removes that friction entirely. Upload your file, type your question in plain English, and let a Gemma 4-powered agentic backend inspect the schema, generate DuckDB SQL, execute it, and return the results — right inside a clean chat interface. 🎯

👉 Try it live here: https://databro.dev/backend/ai-data-chat/

🛠️ The Full Stack at a Glance

Before diving deep, here’s the architecture that powers this tool:

Layer Technology
Frontend Next.js (Portfolio App)
Edge Middleware Supabase Edge Functions
Backend API FastAPI on Google Cloud Run
AI Agents CrewAI
SQL Engine DuckDB
LLM Gemma 4 via Hugging Face Inference API
File Formats CSV, XLSX, Parquet, JSON, Arrow, IPC

🌟 Meet the Gemma 4 Model Family

Gemma 4 is Google DeepMind’s latest family of open models — and they are genuinely exciting for agentic builders. Unlike earlier iterations, Gemma 4 is built with logic-heavy, reasoning-oriented workflows in mind, which makes it a natural fit for tasks like natural-language-to-SQL generation.

Here’s a builder-friendly overview of the lineup:

Model Context Window Why It’s Interesting
Gemma 4 E2B 128K tokens Smallest effective-size model — ultra-deployable, great for edge and lightweight inference
Gemma 4 E4B 128K tokens Stronger capability with multimodal notes — a sweet middle ground for multi-step tasks
Gemma 4 26B A4B 256K tokens Mixture-of-Experts architecture — advanced reasoning with long context, perfect for agentic workflows
Gemma 4 31B 256K tokens Largest dense instruct-tuned model — maximum open-model capability for complex tasks

The AI Data Chat tool exposes google/gemma-4-31B-it and google/gemma-4-26B-A4B-it via Hugging Face endpoints. Once you see these models turn a fuzzy natural-language question into a working DuckDB query over your own uploaded file, it becomes very hard not to imagine ten more use cases. 🔥

🤖 A Brief Foundation on CrewAI

CrewAI is an open-source framework for orchestrating autonomous AI agents in structured multi-agent workflows. Instead of a single giant prompt doing everything, CrewAI lets you break a problem into specialized, coordinated responsibilities.

Three building blocks to understand:

🧑‍💼 Agents

Specialized workers, each with a defined role. Think of them as employees on your AI team — one might be a “Schema Inspector,” another a “SQL Writer,” another a “Result Formatter.”

📋 Tasks

Units of work assigned to agents. A task has a clear description, expected output, and the agent responsible for it. Examples:

  • “Analyze the uploaded file and return its schema”
  • “Given this schema and user intent, write a valid DuckDB SQL query”
  • “Execute the SQL and format the result for the user”

🔧 Tools

Capabilities agents can invoke to act on the world — file inspection utilities, DuckDB query executors, schema extractors, etc.

This model is powerful because it creates transparent, inspectable pipelines instead of black-box AI magic. Every step has a purpose, and every output is traceable.

🏗️ How the Tool Is Built — End to End

Step 1 — The Next.js Frontend

The portfolio app at databro.dev/backend/ai-data-chat/ provides a split-panel chat UI:

  • Left panel: File upload zone (drag & drop), attached file display with name and size, LLM Settings panel with Provider + Model selectors
  • Right panel: Chat interface with starter prompt buttons, conversation history with SQL panels and output tables

The user picks a file, selects a Gemma 4 model via Hugging Face, and types a natural-language question. The frontend packages everything into FormData (file + user intent + provider + model) and sends it to a Supabase Edge Function.

Step 2 — Supabase Edge Function

A lean TypeScript Edge Function validates the multipart request and proxies it to the Google Cloud Run backend. This keeps the frontend thin, avoids exposing Cloud Run endpoints directly to the browser, and centralizes auth concerns at the edge.

Step 3 — FastAPI on Google Cloud Run

The Cloud Run backend receives the uploaded file and user intent. It:

  1. Stores the file in a temporary directory
  2. Detects file type (CSV, Parquet, Arrow, JSON, XLSX)
  3. Loads the data into DuckDB as a data table
  4. Runs DESCRIBE data to extract the full schema
  5. Counts total rows
  6. Passes schema context + row count + user intent to the CrewAI agent pipeline

Step 4 — CrewAI Agent Pipeline

The agentic pipeline runs two coordinated tasks:

Task 1 — Schema Analysis
An agent uses a DuckDB tool to introspect the uploaded file and returns column names, types, and a row count. This grounding step is critical — the LLM sees real column names before generating SQL, which dramatically reduces hallucination.

Task 2 — SQL Generation + Execution
The schema and user intent are passed to the Gemma 4 model via Hugging Face. The LLM returns a valid DuckDB SQL query. The agent then executes that SQL against the uploaded file and serializes the results.

Step 5 — Response Back to the UI

The backend returns a structured JSON response containing:

  • model — the resolved model used
  • sql — the exact DuckDB SQL that was generated and run
  • schema — column definitions
  • total_rows — row count of the uploaded file
  • result — the query output rows

The frontend renders the SQL in a dark code panel and the results in a scrollable table. Transparency is built in — you always see exactly what query was run.

🎬 Live Demo — Real Queries, Real Results

I tested the tool with a real e-commerce sales CSV (49 rows, 18 columns covering orders, customers, products, categories, regions, sales, profit, discounts, and returns). Here’s what Gemma 4 + CrewAI + DuckDB produced for 6 progressively complex natural language questions.

✅ Test Setup — File Uploaded, Gemma 4 31B Selected

The file sample_test_file.csv is attached (8.7 KB). Provider set to Hugging Face, model set to Gemma 4 31B Instruct. The chat interface is ready with starter prompts visible.

Upload & model selection — sample_test_file.csv loaded, Hugging Face + Gemma 4 31B Instruct selected

💡 The LLM Settings panel (visible by scrolling right on the chat panel) lets you switch between Gemma 4 31B Instruct and Gemma 4 26B A4B Instruct mid-session.

🟢 Query 1 — “Show me the top 10 rows”

What Gemma 4 generated:

SELECT * FROM data LIMIT 10

Result: Processed 49 CSV rows, returned 10 of 10 matching rows. Clean tabular output showing order_id, customer_id, customer_name, customer_email, customer_segment and more.

Query 1 — Top 10 rows result with SQL EXECUTED panel and tabular output

🟢 Query 2 — “What are the key columns and null rates?”

This is where things get interesting. Instead of a simple SELECT, Gemma 4 understood intent — it generated a full null-rate audit query covering every single column:

What Gemma 4 generated:

SELECT
    count(*) AS total_rows,
    (count(*) - count(order_id)) * 100.0 / count(*) AS order_id_null_rate,
    (count(*) - count(customer_id)) * 100.0 / count(*) AS customer_id_null_rate,
    (count(*) - count(customer_name)) * 100.0 / count(*) AS customer_name_null_rate,
    (count(*) - count(customer_email)) * 100.0 / count(*) AS customer_email_null_rate,
    -- ... (every column covered)
    (count(*) - count(returned)) * 100.0 / count(*) AS returned_null_rate
FROM data

Result: 49 total rows, 0% null rate across all 18 columns — a complete data quality snapshot in one shot, without the user writing a single line of SQL.

Query 2 — Full null rate audit SQL generated by Gemma 4, covering all 18 columns

Query 2 — SQL OUTPUT showing 49 total rows with 0 null rates across all key columns

🤯 This is the magic moment. A non-technical user typed a plain English question, and the system produced a production-quality data quality audit query autonomously.

🟡 Query 3 — “Show top 5 categories ranked by total sales and profit margin percentage”

What Gemma 4 generated:

SELECT
    category,
    SUM(sales) AS total_sales,
    (SUM(profit) / SUM(sales)) * 100 AS profit_margin_percentage
FROM data
GROUP BY category
ORDER BY total_sales DESC
LIMIT 5

Result: 3 categories returned with calculated profit margins:

category total_sales profit_margin_percentage
Furniture 4438.45 -4.08% ⚠️
Technology 4228.69 +20.16% ✅
Office Supplies 918.77 +37.42% 🏆

Insight unlocked: Furniture is the highest-revenue category but is running at a loss. Technology and Office Supplies are both profitable — Office Supplies has the best margin.

Query 3 — Category sales and profit margin percentage ranked, showing Furniture at a loss

🟡 Query 4 — “Show me monthly revenue trend with total orders count and average order value grouped by month”

What Gemma 4 generated:

SELECT
    date_trunc('month', order_date) AS month,
    SUM(sales) AS total_revenue,
    COUNT(order_id) AS total_orders,
    AVG(sales) AS avg_order_value
FROM data
GROUP BY date_trunc('month', order_date)
ORDER BY month

Result:

month total_revenue total_orders avg_order_value
2024-01-01 6471.26 30 215.71
2024-02-01 3114.65 19 163.93

Gemma 4 correctly used DuckDB’s native date_trunc('month', ...) function — not a workaround, not a SUBSTR hack — real DuckDB date intelligence applied from natural language.

Query 4 — Monthly revenue trend using date_trunc, showing Jan and Feb 2024 with order counts and avg values

🔴 Query 5 — “Which customers have placed more than one order? Show their name, order count and total spend ranked by highest spend”

What Gemma 4 generated:

SELECT
    customer_name,
    COUNT(DISTINCT order_id) AS order_count,
    SUM(sales) AS total_spend
FROM data
GROUP BY customer_name
HAVING COUNT(DISTINCT order_id) > 1
ORDER BY total_spend DESC

Result:

customer_name order_count total_spend
Alice Johnson 3 163.85

Gemma 4 correctly identified the need for HAVING COUNT(DISTINCT ...) > 1 — a classic SQL pattern that trips up many developers, generated correctly from plain English.

Query 5 — Repeat customer analysis using COUNT DISTINCT + HAVING, correctly identifying Alice Johnson

🔴 Query 6 — “Find all returned orders, show total profit loss by category and ship mode, sorted by biggest loss first”

What Gemma 4 generated:

SELECT
    category,
    ship_mode,
    SUM(profit) AS total_profit_loss
FROM data
WHERE returned = 'Yes'
GROUP BY category, ship_mode
ORDER BY total_profit_loss ASC

Result:

category ship_mode total_profit_loss
Furniture Second Class -200 🚨
Furniture Standard Class -155 ⚠️
Furniture First Class -20

Every single return came from Furniture. Second Class shipping returns caused the most financial damage. This is exactly the kind of insight a business analyst would spend hours finding — delivered in under 30 seconds via plain English.

Query 6 — Returned orders profit loss analysis by category and ship mode, showing Furniture returns at -$200, -$155, -$20

💡 Why This Is Genuinely Useful — No SQL Expertise Needed

Look at what just happened across those 6 queries:

What the user typed What DuckDB actually executed
“Show me the top 10 rows” SELECT * FROM data LIMIT 10
“What are the key columns and null rates?” 18-column null rate audit with (count(*) - count(col)) * 100.0 / count(*)
“Top categories by sales and profit margin” SUM + division for margin %, GROUP BY, ORDER BY
“Monthly revenue trend” date_trunc('month', order_date) + AVG + COUNT
“Customers with multiple orders” COUNT(DISTINCT ...) + HAVING clause
“Returned orders profit loss by category” WHERE returned = 'Yes' + GROUP BY + ORDER BY ASC

I automated PDF generation for 1,600 security guides — WeasyPrint lessons

Last year I made a decision I slightly regret: I promised PDF downloads for every security guide on my site. At the time I had around 400 articles. By the time I finished building the pipeline, I had over 1,600. I am not proud of everything I wrote to make this work, but it works reliably in production and I learned a lot along the way.

Why WeasyPrint

I looked at several options: Puppeteer (Node, I wanted to avoid it), wkhtmltopdf (abandoned, known rendering issues), and WeasyPrint. WeasyPrint is a Python library that converts HTML/CSS to PDF using CSS Pint and Cairo under the hood. It is not the fastest, but it respects CSS print media queries, handles Unicode properly, and does not require a browser binary with a display server.

For a cybersecurity consulting site generating PDFs from server-rendered HTML, that tradeoff was acceptable.

The basic pipeline

Each article lives at a URL like https://ayinedjimi-consultants.fr/articles/{slug}. The generation script fetches that URL directly via localhost:4001 (the Go Fiber backend) and writes a PDF to /var/www/ayinedjimi-prod/public/static/pdf/{slug}.pdf.

import subprocess
import os
from pathlib import Path

def generate_pdf(slug: str, base_url: str = "http://localhost:4001") -> Path:
    url = f"{base_url}/articles/{slug}"
    output_path = Path(f"/var/www/ayinedjimi-prod/public/static/pdf/{slug}.pdf")
    output_path.parent.mkdir(parents=True, exist_ok=True)

    result = subprocess.run(
        [
            "weasyprint",
            "--optimize-images",
            "--uncompressed-pdf",  # easier to merge later
            url,
            str(output_path),
        ],
        capture_output=True,
        text=True,
        timeout=120,
    )

    if result.returncode != 0:
        raise RuntimeError(f"WeasyPrint failed for {slug}:n{result.stderr}")

    return output_path

Then, for guides that needed a branded cover page, I used pikepdf to merge a static cover PDF in front of the generated content:

import pikepdf

def prepend_cover(pdf_path: Path, cover_path: Path) -> None:
    with pikepdf.open(cover_path) as cover, pikepdf.open(pdf_path) as content:
        merged = pikepdf.Pdf.new()
        merged.pages.extend(cover.pages)
        merged.pages.extend(content.pages)
        # overwrite in place
        merged.save(pdf_path)

This is simple but effective. The cover is a static PDF I designed once in Inkscape and never touch again.

The print CSS problem

The biggest time sink was not the Python code — it was CSS.

Web CSS and print CSS are two different problems. My site uses Tailwind CSS, and Tailwind does almost nothing useful for @media print. Navigation bars, sticky headers, cookie banners, and dark-mode backgrounds all came through in the PDF looking terrible.

I added a dedicated @media print block to my stylesheet:

@media print {
  header, footer, nav, .cookie-banner, #mobile-cta-bar, .comments-section {
    display: none !important;
  }

  body {
    background: white !important;
    color: black !important;
    font-size: 11pt;
    line-height: 1.5;
  }

  pre, code {
    white-space: pre-wrap;
    word-break: break-word;
    border: 1px solid #ccc;
    padding: 0.5em;
    font-size: 9pt;
  }

  h2 {
    page-break-before: auto;
    page-break-after: avoid;
  }

  table {
    page-break-inside: avoid;
  }

  a[href]::after {
    content: " (" attr(href) ")";
    font-size: 8pt;
    color: #555;
  }
}

The page-break-after: avoid on headings prevents the embarrassing situation where a section title appears at the very bottom of a page with its content on the next one. The a[href]::after rule appends URLs in parentheses, which matters for printed security checklists where readers might want to look something up.

Font handling on a headless server

On my development machine, WeasyPrint found all fonts fine. On the server (Ubuntu 22.04, no desktop environment), it fell back to a generic serif and the output looked wrong.

The fix was to install fonts explicitly and ensure WeasyPrint could find them:

sudo apt-get install -y fonts-open-sans fonts-liberation fontconfig
fc-cache -fv

I also added a @font-face rule pointing to locally hosted font files rather than a CDN, because WeasyPrint makes HTTP requests to fetch external resources and CDN latency adds up at 1,600 articles.

File permissions and www-data

My Go Fiber backend runs as www-data. The PDF generation script also needed to write to the same directory. I initially ran the script as root in a cron job, which worked but created files owned by root that the web server could still read — until I started needing to regenerate individual PDFs from within the application itself.

The clean solution was to run the cron job as www-data:

# in crontab for www-data
0 2 * * * /opt/ayinedjimi-src/scripts/generate-pdfs.py >> /var/log/pdf-gen.log 2>&1

And ensure the output directory is owned by www-data from the start. If you mix ownership in a directory that a process needs to write to, you will chase confusing permission errors for longer than you want to admit.

What I wish I had done differently

Incremental generation from day one. My first version regenerated all PDFs every night. That takes about 4 hours for 1,600 articles. The smarter approach, which I eventually built, is to track a pdf_generated_at timestamp in the database and only regenerate articles updated since the last run.

Separate the generation from the serving. WeasyPrint is memory-hungry. On my 4GB VPS it sometimes consumed over 1GB for complex articles with many images. Running generation as a background process with a queue rather than inline would have been cleaner.

Test print CSS early. I spent three evenings fixing print CSS issues I could have caught by pressing Ctrl+P in my browser on day one.

The full pipeline is now stable. PDF coverage is above 54% of published articles (I exclude short news items and blog posts). The security checklists section was actually the original motivation — users needed printable, offline-usable versions of hardening guides for firewall audits where internet access is restricted.

If you are building something similar, WeasyPrint is a legitimate choice. Just budget more time for print CSS than you think you need.

I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish security hardening checklists for FortiGate, Palo Alto, Active Directory, and more — free PDF and Excel.

Anker’s 0-to-1: What a $3000 Startup Teaches Us About Replicable Success

Anker’s 0-to-1: What a $3000 Startup Teaches Us About Replicable Success

In my previous post, I analyzed the mature methodology behind Anker — shallow sea strategy, the 1357 product tier system, and the Big Dipper formation for competitive advantage. That’s the 1-to-10 playbook, the system Steven Yang built after they had product-market fit.

But the 0-to-1 story is different. It’s messier, luckier, and far more instructive for founders who haven’t found their first hit yet.

Here’s the full picture — what happened, what made it work, and what can actually be replicated in 2026.

The Timeline: From $3,000 to the First Billion

2011 — The Accidental Discovery

Steven Yang was a senior software engineer at Google. He did something completely unrelated to his later career: he built an automated order processing system for his girlfriend’s Amazon shop.

What he discovered changed everything: Amazon’s marketplace could put a completely unknown brand in front of global consumers.

Most good businesses aren’t “thought up” — they’re stumbled upon while doing something else. Yang didn’t decide to start a company and then discover Amazon. He used Amazon first (for his girlfriend), then decided to start a company.

Late 2011 — The First Capital

Yang asked his mother: should I take VC money or use family money?

Her answer is worth remembering: “If this is gambling, take someone else’s money. If this is what you’re meant to do, use mine.”

He combined his Google savings with his mother’s pharmaceutical career savings — less than $1M in seed capital — and moved from California to Shenzhen.

The source of your 0-to-1 capital shapes the company’s genetics. Money you’re afraid to lose changes your decision quality — usually for the better.

The First Product: Laptop Batteries

The market landscape:

Option Price Quality
OEM laptop battery $100 Good
Cheap knockoff $20 Terrible
Anker’s slot $30-40 Near-OEM quality

Yang found a “no-man’s land”: $100 was too expensive, $20 was too risky. Would people pay $30-40 for near-OEM quality?

Yes, they would. This became their first revenue.

0-to-1 isn’t about finding a blue ocean. It’s about finding a quality gap — the price point where customers will happily pay 50% more to stop worrying about quality.

2012-2013 — The Forced Pivot

The MacBook Air made built-in batteries standard. The laptop battery category was shrinking. Meanwhile, smartphones were exploding.

Yang pivoted from laptop batteries to phone accessories. Their first phone product? A replaceable battery for the HTC Sensation (Android phones still had removable batteries then). Then power banks, cables, chargers.

Your first category probably won’t be your final category. The key is building capability (manufacturing + supply chain + distribution) that can transfer to a bigger market.

2014 — The First Hit: The “Lipstick” Power Bank

The team noticed a very specific problem: women’s bags are small. Regular power banks don’t fit.

They found a Shenzhen factory making lipstick-sized power banks. Anker didn’t design from scratch — they redesigned and improved an existing product.

Result: 1 million units sold. Topped Amazon for 3 consecutive years.

This was Anker’s first “breakout” product. Before this, they were “that decent charger brand on Amazon.”

Your first hit comes from a narrow, overlooked scenario. Not “a better power bank for everyone” — “a power bank that fits in a small bag.” The narrower the entry point, the faster it explodes.

Anker’s Three Hidden Weapons

Weapon 1: The Amazon Review Mining Machine

Yang’s most underrated innovation wasn’t a product — it was a method for finding what to build.

The process: study thousands of Amazon reviews and complaints daily → identify the top 3 customer pain points → design a product that specifically fixes those 3 things → launch → iterate based on new reviews.

Result: Anker’s products weren’t “designed by engineers.” They were complained into existence by customers.

At the 0-to-1 stage, you don’t need genius insights. You need a systematic way to let customers tell you what to build.

Weapon 2: The Happy Card

Every Anker product included a card: “Contact us directly if there’s an issue. Don’t leave a negative review.”

Results: complaints handled privately instead of publicly on Amazon, direct customer relationships established, consistent 4-star ratings — not the best, but the most reliable.

The most valuable early-stage asset isn’t traffic — it’s ratings and word-of-mouth. A “talk to us first” card in every package is worth more than a paid ad campaign.

Weapon 3: PowerIQ

A self-developed technology that auto-detects connected devices and delivers optimal charging power. Technically simple. But in marketing, it transformed Anker from “cheap charger brand” into “the brand with technology.”

Early-stage technology doesn’t need to be revolutionary. It needs to give customers a label they can repeat. “The one that auto-detects your phone” spreads faster than “Anker’s charger.”

What You Can’t Replicate: Three Windows That Closed

1. Amazon’s Traffic Window (2011-2014)

In 2011, few Chinese sellers were on Amazon. Competition was minimal, ad costs were low, and the platform was hungry for third-party sellers. Yang wasn’t “grabbing share in a crowded market” — he was planting flags on empty land.

Today: thousands of competitors per category, CPC costs of $1-5, Amazon pushing its own Basics line, and 4.5-star products everywhere.

2. The Shenzhen Manufacturing + Three-Way Arbitrage

In 2011, Yang was one of the few people who simultaneously understood three things:

  • What US consumers wanted and what they’d pay
  • How Amazon’s marketplace and rules worked
  • What Shenzhen’s supply chain could actually do

Today, that three-way knowledge is table stakes for every cross-border ecommerce operator.

3. The Category White Space

In 2011, the charging accessories market had OEM ($100) and garbage ($20) — with nothing in between. Today, search “power bank” and you’ll find hundreds of brands at every price point, all rated 4.5+. That quality gap has been filled.

What You CAN Replicate: Four Patterns That Haven’t Aged

Pattern 1: Find Demand on a Platform, Don’t Guess It

Yang’s sequence: find a platform (Amazon) → study complaints → build product. Most founders do the reverse: have a product idea → validate on a platform.

The 2026 version: Find a platform where users publicly complain (Reddit subreddits, Xiaohongshu comments, GitHub Issues, Discord servers). Let their complaints tell you what to build.

Pattern 2: Don’t Be the Cheapest, Don’t Be the Best — Be the Most Reliable Middle Ground

Anker owned the “four-star review” slot — between five-star OEM and three-star no-name.

The 2026 version: In any category, “best value” scales better than “cheapest” or “premium.” Find the price point where customers pay 50% more for “won’t let me down.”

Pattern 3: The First Hit Comes From a Ridiculously Specific Scenario

Not “a better power bank” — “a power bank that fits in small bags.”

The 2026 version: Don’t build “an AI writing tool.” Build “an AI writing tool for cross-border ecommerce listings.” Don’t build “project management software.” Build “a progress tracker for renovation contractors.” Narrow entry, wide exit.

Pattern 4: Early Tech Doesn’t Need to Be Revolutionary — It Needs to Be Shareable

PowerIQ wasn’t hard to build. But it gave customers a one-liner: “It’s the one that automatically detects your phone’s charging needs.”

The 2026 version: Your first feature doesn’t need to be a patentable invention. It needs to be describable in one sentence that a customer would actually say to a friend.

How to Apply This Framework in 2026

Don’t start a charger brand (red ocean). Don’t expect Amazon blue oceans (gone). But the framework works:

Step What to Do Anker’s Equivalent
1 Find a “high-density complaint” platform Amazon review mining
2 Identify a quality gap (“clearly better than budget, 60% less than premium”) Laptop battery positioning
3 Build a first product for a very narrow scenario Lipstick power bank
4 Create a trust mechanism (direct founder access, no-questions refund) Happy Card
5 Give the product a label customers repeat PowerIQ

0-to-1 vs 1-to-10: Completely Different Games

Dimension 0-to-1 Phase (2011-2014) 1-to-10 Phase (2014-onward)
Strategy Find quality gaps, test fast Shallow sea, systematic expansion
Innovation Improve existing products 9-grid innovation framework
Team A few friends from Google/Peking U 6,000-person President+Federation
Pricing $30-40 sweet spot 1357 tiered system
Competition One person can win Big Dipper formation

Anker’s mature methodology fills the LatePost interview. But the blood, sweat, and luck of 0-to-1 isn’t in that article.

Read the companion Chinese version on our blog. This is the second in a two-part series: Part 1: The five principles uniting Anker and community-building

I built a search engine over 1,600+ cybersecurity articles — here’s what I actually learned

A year ago I had a problem: 1,600+ cybersecurity articles spread across a Go backend, and a search bar that returned garbage.

The standard MySQL LIKE '%keyword%' approach was embarrassing. Searching “pentest Active Directory” returned articles that happened to contain the word “pentest” on one side and “Directory” somewhere else — totally unrelated content ranked first.

So I rebuilt it from scratch. Here’s the honest version of what happened.

The stack I chose (and why)

My backend is Go Fiber. I needed something that:

  • Handled typos (users search “kerberosting” not “kerberoasting”)
  • Returned results in < 50ms
  • Could be self-hosted (no SaaS dependency for a small site)
  • Had a decent Go client

I went with Meilisearch. Not because it’s technically the best for every use case, but because it hit every point above and took 20 minutes to set up.

// Sync article index on startup
func SyncMeilisearch(client *meilisearch.Client, articles []Article) error {
    index := client.Index("articles")

    docs := make([]map[string]interface{}, len(articles))
    for i, a := range articles {
        docs[i] = map[string]interface{}{
            "id":          a.ID,
            "title":       a.Title,
            "slug":        a.Slug,
            "excerpt":     a.Excerpt,
            "category":    a.Category,
            "tags":        a.Tags,
            "published_at": a.PublishedAt,
        }
    }

    _, err := index.AddDocuments(docs)
    return err
}

The index auto-syncs on startup and updates via CRUD hooks — so every time an article is created, updated or deleted, Meilisearch stays in sync.

What surprised me: the content problem

After the first week, I had a painful realization: search quality is mostly a content problem, not a tooling problem.

Meilisearch was doing its job. But my articles had inconsistent metadata. Some had rich excerpts, others had none. Tags were applied loosely. Category assignments were sometimes wrong.

Three things I fixed that made the biggest difference:

1. Enforce excerpt quality at write time

I added validation that rejects articles without a proper excerpt (minimum length, no boilerplate phrases). This is boring to implement and nobody wants to do it. Do it anyway.

2. Category filtering beats keyword search

For a domain-specific corpus, letting users pre-filter by category (news / guide / analysis / checklist) reduces the search space dramatically. Precision goes up even when relevance ranking isn’t perfect.

GET /api/search?q=kerberoasting&cat=guide&limit=10

3. Fallback matters

Meilisearch goes down. Rarely, but it does. I added a MySQL LIKE fallback that kicks in automatically:

results, err := SearchMeilisearch(query, filters)
if err != nil || len(results) == 0 {
    results, err = SearchMySQL(query, filters) // fallback
}

Users never noticed the degradation. That’s the goal.

The retrieval part: what “RAG” actually means at this scale

I see a lot of articles about building RAG systems with vector embeddings, chunking strategies, cosine similarity, etc. That’s the right approach when your questions are complex and open-ended.

For a domain-specific article corpus with structured metadata, it’s overkill. What I actually needed was:

  • Fast keyword + semantic-ish retrieval (Meilisearch handles this with its ranking rules)
  • A way to surface the right article given a user query
  • Context injection into LLM prompts when generating summaries or related content

The architecture ended up being:

User query
    → Meilisearch (retrieval, ~10-30ms)
    → Top 3-5 articles (slug + title + excerpt)
    → LLM prompt context
    → Generated response / enriched content

No vector DB. No embeddings pipeline. No chunking headaches. For 1,600 articles averaging 2,000 words each, this works well.

Honest numbers

Metric Before After
Avg search latency 340ms (MySQL LIKE) 28ms (Meilisearch)
Typo tolerance None Handles 1-2 char errors
Multi-word queries Poor Good
Index size N/A ~12MB
Setup time — ~2 hours total

The 12MB index for 1,600+ articles is worth emphasizing — Meilisearch is lean.

What I’d do differently

1. Index full content, not just excerpts

I indexed titles, slugs, excerpts and tags — but not the full article body. This means searching for a technical term that appears deep in an article content returns nothing. I’m fixing this progressively.

2. Add synonyms from day one

Meilisearch has a synonyms API. I should have built a synonyms list for cybersecurity terminology immediately:

{
  "AD": ["Active Directory"],
  "pentest": ["penetration test", "intrusion test"],
  "MFA": ["multi-factor authentication", "2FA"]
}

I added these late, after noticing obvious query misses.

3. Log every failed search

The most valuable dataset I have is the list of searches that returned zero results. It tells you exactly what content you’re missing and what synonyms to add. I started logging these to a search_misses table — should have done it from the start.

The takeaway

If you’re building a content-heavy site and want good search without a massive infrastructure investment:

  1. Meilisearch is genuinely good and genuinely easy
  2. Content quality beats algorithmic cleverness every time
  3. For domain-specific retrieval, you don’t need vector embeddings unless your queries are conversational/open-ended
  4. Log your zero-result searches — it’s free product research

The full search endpoint with category/difficulty/type filters, pagination and Meilisearch/MySQL fallback is about 80 lines of Go. Happy to share if useful.

I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. The corpus covers pentesting, Active Directory, cloud security and compliance — including 17 free security hardening checklists (PDF + Excel).

I used LLMs to rewrite meta descriptions for 1,600 articles — honest results

Meta descriptions are the most underrated SEO element on content-heavy sites.

They don’t affect rankings directly, but they determine whether someone clicks your result in Google. A bad meta description on a well-ranked article is traffic you’re leaving on the table.

I had 1,600+ cybersecurity articles. About 40% had no meta description at all. Another 30% had descriptions that were either truncated, keyword-stuffed, or copy-pasted from the first paragraph (which almost never makes a good description).

So I automated the rewrite. Here’s what actually happened.

The constraint: 140-160 characters, every time

The rule is simple and brutal: meta descriptions must be 140-160 characters. Not words — characters. Including spaces.

Under 140: Google often ignores your description and rewrites it automatically (usually badly).
Over 160: truncated with “…” in search results, which kills CTR.

This is harder than it sounds when you’re generating text with an LLM. The model has no natural understanding of character counts — it optimizes for coherence, not length.

My first naive prompt:

Write a meta description for this article about {topic}.
Keep it under 160 characters.

Results: descriptions ranging from 95 to 210 characters. Useless.

The prompt engineering that actually worked

After a lot of iteration, the prompt that consistently landed in the 140-160 range:

Write a meta description for this cybersecurity article.

Rules:
- EXACTLY 140 to 160 characters (count carefully, including spaces)
- Start with an action verb or a direct hook
- Include the main topic and one concrete benefit
- No buzzwords (comprehensive, ultimate, complete)
- No "In this article" or "This guide"

Article title: {title}
Article excerpt: {excerpt}
Main keywords: {keywords}

Output only the description, nothing else.

The key changes:

  1. “EXACTLY” instead of “under” — models respect hard constraints better than soft ones
  2. Positive framing of what to include, not just what to avoid
  3. Strip all meta-commentary — “Output only the description” eliminates the model explaining what it did

Even with this prompt, I got out-of-range results ~15% of the time. So I added a validation + retry loop.

The validation pipeline

import re

def validate_meta_description(desc: str) -> dict:
    length = len(desc)

    issues = []
    if length < 140:
        issues.append(f"Too short: {length} chars (min 140)")
    if length > 160:
        issues.append(f"Too long: {length} chars (max 160)")
    if desc.startswith(("In this", "This article", "This guide")):
        issues.append("Starts with forbidden phrase")
    if re.search(r'b(comprehensive|ultimate|complete)b', desc, re.I):
        issues.append("Contains buzzword")

    return {
        "valid": len(issues) == 0,
        "length": length,
        "issues": issues,
    }

def generate_meta_description(title: str, excerpt: str, keywords: list, 
                               max_retries: int = 3) -> str:
    for attempt in range(max_retries):
        desc = call_llm(build_prompt(title, excerpt, keywords))
        result = validate_meta_description(desc)

        if result["valid"]:
            return desc

        # Retry with explicit correction hint
        if attempt < max_retries - 1:
            hint = f"Previous attempt failed: {', '.join(result['issues'])}. Try again."
            # inject hint into next prompt

    return None  # manual review needed

After 3 retries, I flagged remaining failures for manual review. About 4% needed human intervention.

The results: honest numbers

I ran this across 640 articles (the ones with missing or clearly bad descriptions first).

Outcome Count %
Valid on first try 487 76%
Valid after retry 115 18%
Failed (manual review) 38 6%

Quality assessment (I manually reviewed a random sample of 80):

  • 71% — better than what I had before
  • 22% — similar quality
  • 7% — worse (usually missing context that wasn’t in the excerpt)

The 7% worse cases had a common pattern: articles where the excerpt was weak or missing. The model had nothing to work with. This is the content problem again — LLMs can’t fix bad source material.

What I measured in search console

I waited 6 weeks after the bulk update before looking at data (Google needs time to recrawl and the signal needs to stabilize).

Results on the articles that were updated vs. a control group that wasn’t:

  • CTR: +0.8 percentage points average (statistically significant at this scale)
  • Impressions: unchanged (as expected — meta descriptions don’t affect rankings)
  • Position: unchanged (also expected)

0.8pp CTR improvement across 640 articles with meaningful traffic adds up. It’s not a dramatic transformation — anyone promising dramatic results from meta description optimization is lying to you. But it’s real and it’s free once the pipeline is built.

The unexpected failure: duplicate descriptions

One thing I didn’t anticipate: the model started producing structurally similar descriptions across articles in the same category.

When I had 50 guides about Active Directory security, many descriptions ended up following the same pattern:

“Learn how to [verb] [AD concept] to protect your environment from [threat]. Step-by-step guide with [tool].”

Technically valid. Practically, if someone searches and sees 5 results from the same site with near-identical descriptions, they’ll click none of them.

Fix: I added a deduplication check that compares new descriptions against already-generated ones using simple n-gram similarity. If similarity > 0.7, force a regeneration with an explicit instruction to use a different structure.

Things I’d do differently

1. Fix excerpts before running LLM generation

The quality of the generated description is directly proportional to the quality of the excerpt. I should have audited and fixed all excerpts first. I did it in the wrong order.

2. Category-specific prompts

A prompt for a “news” article should be different from a “guide” or “checklist” article. News descriptions need urgency; guides need the benefit; checklists need the scope. I used one prompt for everything and paid for it in quality.

3. Track CTR per article, not just aggregate

I know the average improved, but I don’t know which specific articles drove the improvement. Better instrumentation would let me learn which description styles work for which query intents.

The actual takeaway

LLMs are genuinely useful for this kind of bulk text generation task if you:

  1. Write a tight prompt with hard constraints
  2. Build validation + retry logic (don’t trust the model to self-validate)
  3. Have decent source material to work from
  4. Measure the actual downstream metric (CTR), not a proxy

They’re not useful if you expect them to compensate for bad content strategy. Garbage in, slightly better-formatted garbage out.

I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. Content covers pentesting, Active Directory, cloud security and compliance. 17 free hardening checklists available (PDF + Excel) — FortiGate, Palo Alto, pfSense, Active Directory and more.

Per-user cost attribution for your AI APP

You ship your AI feature. It works. A week later your OpenAI bill is $400 and you have no idea which of your users caused which $0.05.

This is the single most underrated metric in production LLM apps — cost per end-user — and it’s surprisingly easy to instrument if you know what to do.

Here are the three approaches I’ve found work in practice, ranked by setup time.

Approach 1: Wrap your provider client (5 minutes)

Works for Express, Next.js Route Handlers, Fastify — anything that has a single OpenAI or Anthropic client instance.

import OpenAI from 'openai'
import { wrapOpenAI, withTrace } from '@voightxyz/openai'

const openai = wrapOpenAI(new OpenAI(), {
  agent: 'production-chat-api',
})

app.post('/api/chat', async (req, res) => {
  await withTrace(
    async () => {
      const r = await openai.chat.completions.create({
        model: 'gpt-4o-mini',
        messages: req.body.messages,
      })
      res.json({ reply: r.choices[0].message })
    },
    {
      routeTag: 'POST /api/chat',
      tags: {
        userId: req.user.id,
        plan: req.user.plan,
      },
    },
  )
})

The trick is withTrace({ tags: { userId } }) at the request boundary. Every LLM call inside the block — direct or nested — inherits those tags automatically via AsyncLocalStorage. You don’t have to thread userId through every function.

Pros: simplest. Pros: works with both OpenAI and Anthropic the same way.
Cons: requires you to use the dedicated wrapper SDKs.

Approach 2: OpenTelemetry telemetry metadata (Vercel AI SDK)

If you’re on the Vercel AI SDK, experimental_telemetry.metadata is the equivalent hook:

import { openai } from '@ai-sdk/openai'
import { streamText } from 'ai'

export async function POST(req: Request) {
  const result = streamText({
    model: openai('gpt-4o-mini'),
    prompt: (await req.json()).prompt,
    experimental_telemetry: {
      isEnabled: true,
      metadata: {
        userId: session.user.id,
        plan: session.user.plan,
      },
    },
  })
  return result.toAIStreamResponse()
}

This lifts onto ai.telemetry.metadata.<key> span attributes that any OpenTelemetry-compatible observability tool (Langfuse, Phoenix, Voight, Braintrust, Datadog) picks up.

Pros: zero coupling — pure OTel, swap exporters whenever.
Cons: only works if your SDK emits OTel spans. AI SDK does. Many others don’t yet.

Approach 3: Raw event emission (autonomous bots / non-HTTP)

For background workers, agents calling LLMs in loops, or anything that doesn’t have a request boundary — emit events manually:

import { Voight } from '@voightxyz/sdk'

const voight = new Voight({ agentId: 'my-bot' })

const t0 = Date.now()
const res = await fetch('https://api.openai.com/v1/chat/completions', {
  method: 'POST',
  headers: { authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
  body: JSON.stringify({
    model: 'gpt-4o-mini',
    messages: [...],
  }),
}).then((r) => r.json())

voight.log({
  type: 'reasoning',
  model: 'gpt-4o-mini',
  durationMs: Date.now() - t0,
  outcome: 'success',
  metadata: {
    tokens: {
      input: res.usage.prompt_tokens,
      output: res.usage.completion_tokens,
    },
    tags: {
      userId: job.userId,
      tenantId: job.tenantId,
    },
  },
})

This is more code per call, but you control everything. Useful when the LLM call doesn’t fit cleanly inside a wrapper (e.g. you’re proxying through your own router).

Pros: full control over what gets emitted.
Cons: more boilerplate. You’re responsible for token counting.

What you can answer once userId is in your tags

Once tags.userId (or whatever you name it) is on every event, the questions you can answer change shape:

Question How
Which user costs me the most this month? Group by tags.userId, sum cost
Is my free tier subsidising power users? Filter by tags.plan: 'free' + sort by cost
Did our last release explode anyone’s bill? Filter by tags.userId + date range
What’s our cost-to-revenue ratio per customer? Join with your Stripe data on userId

You don’t need a separate analytics SDK on the client. You don’t need to copy userId into LLM messages. You don’t need anything custom on top — the tags propagate from the request boundary down to every span.

A note on GDPR / multi-tenant safety

userId here means your internal stable identifier — user_a3f9c2 or whatever — not the user’s email or wallet. Never put PII into telemetry metadata. The good observability tools scrub PII anyway, but garbage-in is still garbage.

For multi-tenant SaaS, add a second tag: tags: { userId, tenantId }. That way you can ask both “which customer is this?” and “which of their users?”.

Wrapping up

Three approaches, one mental model: stamp userId at the boundary, let it propagate to every LLM call inside the request.

The wrappers I used here are Apache 2.0:

Same approach works with Langfuse, Phoenix, Braintrust, or your existing OTel pipeline — the metadata.userId pattern is the universal part.

How do you currently track per-user spend in your AI app? Stripe metering? Server logs? Or have you been flying blind?