I Gave an AI Agent a Telegram Bot and It Started Editing Videos

I wanted to test something simple:

Could an autonomous AI agent receive a video from Telegram, process it automatically, write its own Python code, and send the result back to me?

Turns out:

Yes.

And surprisingly, it worked better than I expected.

The Setup

I deployed an OpenClaw agent on GetClawCloud and connected it to a Telegram bot.

The task sounded straightforward:

  1. I send a video to Telegram
  2. The AI agent receives the file
  3. It extracts the last frame from the video
  4. Sends the image back to me automatically

But what made this interesting was:

I didn’t manually write the processing script.

The agent generated it by itself.

What the Agent Actually Did

After receiving the video, the agent:

  • analyzed the task
  • decided it needed Python video processing
  • generated a script
  • installed dependencies
  • extracted the final frame
  • saved the image
  • sent the image back through Telegram

The entire workflow was autonomous.

No manual scripting.
No SSH session.
No intervention.

Just a Telegram message triggering an AI workflow.

The Surprising Part

The most interesting thing wasn’t the frame extraction itself.

It was that the agent could reliably operate across multiple steps:

  • receive external input
  • reason about the task
  • generate code
  • execute code
  • manage files
  • return results

This is where autonomous AI agents start feeling less like chatbots and more like runtime workers.

Then I Tried Something More Advanced

Next, I gave the agent a Wavespeed.ai API key and a simple instruction:

Generate a cinematic video of a spaceship landing in the desert.

The agent:

  • searched the API documentation itself
  • figured out the request format
  • called the API
  • waited for generation
  • downloaded the final video
  • sent the result back to Telegram

That was the moment it started feeling genuinely autonomous.

Not just “AI chat”.

An actual AI worker.

Why Hosting Matters More Than People Think

A lot of AI agent demos look impressive in short clips.

But running agents continuously is a completely different problem.

Long-running autonomous workflows require:

  • persistent storage
  • stable execution
  • background processing
  • reliable networking
  • restart handling
  • runtime isolation

That infrastructure layer is usually where things break.

Especially when agents start:

  • writing files
  • generating code
  • calling APIs
  • handling async tasks

Why I Built GetClawCloud

I mainly built GetClawCloud because I wanted a simpler way to run OpenClaw agents reliably without constantly managing VPS infrastructure.

For workflows like this, it handles:

  • persistent runtime
  • always-on execution
  • file storage
  • autonomous task execution

without me needing to manually babysit servers.

I also started publishing reusable OpenClaw workflow ideas and prompt templates here:

https://getclawcloud.com/blog/

Final Thoughts

The interesting part of AI agents is no longer conversation.

It’s execution.

Once agents can:

  • interact with APIs
  • generate code
  • process media
  • manage files
  • communicate externally

they start behaving more like autonomous software workers.

This Telegram experiment was one of the first times that actually felt real to me.

Implementando uma pilha a partir de uma fila

Em uma conversa despretenciosa com o Leandro Proença no LinkedIn, mencionei que dá pra implementar pilha usando apenas uma fila.

Bem, vamos fazer isso? Primeiro, vamos definir exatamente o que é “implementar uma estrutura X com uma estrutura Y”, então vamos pro caso mais trivial que é implementar uma fila usando duas pilhas, então vamos implementar uma pilha usando apenas uma fila.

Nota importante aqui, muitos desses resultados e das estratégias usadas são decorrentes de conversas que já tivemos aqui no Computaria, como por exemplo:

Definindo pilhas e filas

Já definimos antes essas estruturas, né? Como em Criando filas e pilhas “apenas” com funções em java, mas ali tinha algumas limitações diferentes (como a questão dos efeitos colaterais) que me levaram a decisões de design… diferentes… do que se encontraria em um código Java selvagem em seu habitat natural.

No caso, aqui vamos usar algo mais parecido com as estruturas de dados que foram usadas para percorrer os grafos em Meu take sobre o algoritmo de Dijkstra:

  • push(element: T) => void
  • pop() => T
  • empty() => boolean

Aqui tanto pilha como fila vão seguir esse mesmo shape: um container que eu posso inserir objetos, recuperar objetos de lá de dentro e verificar se está vazio.

Uma fila com duas pilhas

Bem, aqui a implementação básica é a pilha, então vou precisar instanciar ela. Vou fazer de modo bem simples: um ArrayList que eu insiro e removo do final:

public class Pilha<T> {
    private final ArrayList<T> elementos = new ArrayList<>();

    public void push(T t) {
        elementos.addLast(t);
    }

    public T pop() {
        return elementos.removeLast();
    }

    public boolean empty() {
        return elementos.isEmpty();
    }
}

Ok, agora vamos fazer a partir disso uma Fila<T>. Eu posso simplesmente quando chegar um novo elemento colocar na pilha? Posso, só preciso tomar cuidado na hora de remover o elemento: não posso remover com um simples pop, preciso garantir que seja o primeiro a entrar!

O jeito clássico de fazer isso é usando uma pilha secundária: ao chegar uma requisição de pop, eu recoloco os elementos da pilha principal na pilha secundária. O último elemento removido da pilha principal equivale à parte da frente da fila, portanto precisa ser retornado. Após isso, só colocar de volta no lugar os elementos que foram deslocados (em tese daria para otimizar para muitos pop seguidos, mas aqui o foco não é otimização):

public class Fila<T> {
    private final Pilha<T> elementos = new Pilha<>();
    private final Pilha<T> secundaria = new Pilha<>();

    public void push(T t) {
        elementos.push(t);
    }

    public T pop() {
        while (!elementos.empty()) {
            secundaria.push(elementos.pop());
        }
        final var v = secundaria.pop();
        while (!secundaria.empty()) {
            elementos.push(secundaria.pop());
        }
        return v;
    }

    public boolean empty() {
        return elementos.isEmpty();
    }
}

Existe uma outra maneira de imoplementar isso, que seria pegar o elemento antes do fim. Vai sacando os valores e, ao detectar o fim, retornar o elemento encontrado:

public class Fila<T> {
    private final Pilha<T> elementos = new Pilha<>();
    private final Pilha<T> secundaria = new Pilha<>();

    public void push(T t) {
        elementos.push(t);
    }

    public T pop() {
        while (true) {
            final var candidato = elementos.pop();
            if (elementos.empty()) {
                // devolve os elementos de volta
                while (!secundaria.empty()) {
                    elementos.push(secundaria.pop());
                }
                return candidato;
            }
            secundaria.push(candidato);
        }
    }

    public boolean empty() {
        return elementos.isEmpty();
    }
}

Uma pilha com duas filas

Bem, já que estamos trocando de empurrar no começo pro empurrar no fim, será que dá para pegar o exato mesmo algoritmo de duas pilhas simulando uma fila e fazer duas filas simulando uma pilha? Na real… dá sim!

Não importa o como vai ser implementada a fila em si, apenas que siga a convenção de comportamento. Então, podemos até assumir que vamos usar a fila desenvolvida na seção anterior.

Com isso fora de preoucupação, vamos seguir o mesmo formato de algoritmo:

public class Pilha<T> {
    private final Fila<T> elementos = new Fila<>();
    private final Fila<T> secundaria = new Fila<>();

    public void push(T t) {
        elementos.push(t);
    }

    public T pop() {
        while (true) {
            final var candidato = elementos.pop();
            if (elementos.empty()) {
                // devolve os elementos de volta
                while (!secundaria.empty()) {
                    elementos.push(secundaria.pop());
                }
                return candidato;
            }
            secundaria.push(candidato);
        }
    }

    public boolean empty() {
        return elementos.empty();
    }
}

Uma pilha com uma fila

Vamos pensar aqui qual o objetivo de se usar a fila secundária? Vamos fazer um desenho de como está sendo a questão do uso dessas coisas?

  • eu diminuo a fila principal
  • se eu cheguei no final da fila principal, o último elemento removido precisa ser retornado
  • se eu não cheguei no final da fila principal, preciso guardar o que foi removido
  • após detectar o momento do retorno, tudo que foi removido preciso ser inserido de volta na fila principal

Ou seja, eu preciso de:

  • indicador de fim
  • um buffer para restaurar a fila

E se… eu conseguir fazer isso usando apenas uma única fila? Bem, pra isso eu preciso trabalhar com a fila de modo… especial… No lugar da fila ter apenas elementos do tipo T, preciso que ela tenha um elemento tal que não seja do tipo T que eu possa diferenciar. As operações que eu faço via pilha são todas em cima de T, a API externa não muda. Mas internamente eu preciso de um novo elemento que seja incompatível com os elementos que estou inserindo. Algo semelhante foi usado para poder transformar função parcial em função total no artigo Criando mapas “apenas” com funções em java.

A minha primeira ideia seria fazer com que o backbone da implementação de Pilha<T> fosse Fila<Object> e eu iria inserir forçosamente um new Object(), que ficaria marcado no momento da criação como “elemento de diferenciação”. Mas então lembrei que eu posso fazer algo semelhante a um sum-type usando sealed interface (exploro mais isso no artigo Usando Java moderno para fazer aritmética de Peano):

sealed interface Elemento<V> {
    record Demarcador<V>() implements Elemento<V> {}
    record Container<V>(V v) implements Elemento<V> {}
}

Agora vou usar uma instância Demarcador para indicar que cheguei no final da fila. Se eu tenho um final forte, isso significa que eu posso simplesmente readicionar os elementos que eu tinha após o final da fila. Logo, isso significa que manter uma estrutura secundária apenas por buffer de elementos de torna desnecessário.

Agora, ao dar um pop, preciso verificar se o elemento é o demarcador. Se for, preciso retornar o elemento que foi sacado logo antes dele. Se não for um demarcador, preciso guardar o penúltimo elemento de volta na fila, e reservar o elemento que acabei de sacar para a próxima iteração:

public class Pilha<T> {

    private sealed interface Elemento<V> {
        record Demarcador<V>() implements Elemento<V> {}
        record Container<V>(V v) implements Elemento<V> {}
    }

    private final Fila<Elemento<T>> elementos = new Fila<>();

    public void push(T t) {
        elementos.push(new Elemento.Container<>(t));
    }

    public T pop() {
        elementos.push(new Elemento.Demarcador<>());
        Elemento.Container<T> penultimo = null;
        while (true) {
            final var candidato = elementos.pop();
            if (candidato instanceof Elemento.Container<T> anotherValue) {
                if (penultimo != null) {
                    elementos.push(penultimo);
                }
                penultimo = anotherValue;
            } else {
                return penultimo.v();
            }
        }
    }

    public boolean empty() {
        return elementos.empty();
    }
}

Como teste, adicionei 4 elementos e depois saquei até o esgotamento da pilha:

final var p = new Pilha<String>();
p.push("a");
p.push("b");
p.push("c");
p.push("d");
while (!p.empty()) {
    System.out.println(p.pop());
}

Note algumas coisas interessantes:

  • o elemento Demarcador só é inserido durante o processo de pop, e o processo de pop só termina após sacar o Demarcador
  • eu só preciso fazer a reinserção dos elementos após o segundo saque

Essa observação do “segundo saque” me leva a uma implementação alternativa, que incondicionalmente faz o primeiro saque e reserva o valor, antes mesmo de sequer cogitar inserir o demarcador. A ideia é:

  • saca o elemento
  • se estiver vazio, retorna o elemento
  • se não estiver vazio, reserva esse elemento
  • insere o demarcador
  • continue sacando próximos elementos
  • se o elemento sacado for demarcador, retorne o elemento reservado
  • se não, insira o elemento reservado de volta e reserve o elemento sacado
public T pop() {
    var reservado = (Elemento.Container<T>) elementos.pop();
    if (elementos.empty()) {
        return reservado.v();
    }
    elementos.push(new Elemento.Demarcador<>());
    while (true) {
        final var candidato = elementos.pop();
        if (candidato instanceof Elemento.Container<T> anotherValue) {
            elementos.push(reservado);
            reservado = anotherValue;
        } else {
            return reservado.v();
        }
    }
}

Building an AI-Powered Supply Chain Platform with FastAPI, React Native, and Data Science

Introduction
A couple of weeks ago, I built a full-stack mobile and AI-powered supply chain optimization app designed to simulate the real world. The goal of the project was to build an intelligent system that combines:

  • Mobile development
  • Backend engineering
  • Data analytics
  • Machine learning
    into a single production-style architecture.

The platform built with

  • React Native (Expo + TypeScript)
  • Zustand
  • TanStack Query
  • FastAPI (Backend)
  • MySQL (Database)
  • SQLAlchemy for ORM and database relationships
  • Pandas for analytics and data processing
  • Scikit-learn (Model)
    was designed to support advanced logistic features such as Route optimization, Warehouse analytics, Demand forecasting, Anomaly detection and Shipment delay predictions.

Why I Built This Project
One of the most interesting parts of this project has been bridging mobile engineering with AI engineering. Instead of building isolated ML notebooks, I wanted to build a system where AI was integrated directly into a real application. A system where users can interact with predictions and analytics, get real live operational insights all within the mobile app.

The backend follows a modular structure:app/
app/
├── models/
├── api/
├── schemas/
├── services/
├── analytics/
├── core/
├── db/
├── utils/
└── scripts/

Database Design
The system currently contains three major entities:
Shipments
class ShipmentCreate(BaseModel):
product_name: str
origin: str
destination: str
status: str
distance_km: float
expected_delivery_days: int
actual_delivery_days: int
warehouse_id: Optional[int] = None
driver_id: Optional[int] = None

Warehouses
class WarehouseCreate(BaseModel):
name: str
city: str

Drivers
class DriverCreate(BaseModel):
name: str
truck_number: str

These relationships allowed me to model a simplified logistics network.

Building the Analytics Layer
This was where the project started transitioning from a CRUD application into an AI-powered system.
I used Pandas to convert logistics data into analytics-ready DataFrames.
Example:
shipments_df = pd.read_sql(
“SELECT * FROM shipments”,
engine
)

Shipment Analytics
Some of the analytics currently implemented include:
Shipment Status Analysis
shipments_df[“status”].value_counts()

Delivery Performance
shipments_df[
“expected_delivery_days”
].mean()

Route Analysis
shipments_df.groupby(
[“origin”, “destination”]
).size().sort_values(ascending=False).head()

result = [{
“hospital”: hospital,
“state”: state,

“count”: count

}for (hospital, state), count in delayed_routes.items()
]

shipments_df.groupby(
[“destination”]
).size().sort_values(ascending=False).head()

Product Analysis
result = (
df[“product_name”]
.value_counts(normalize=True)
.mul(100)
.round(2)
).head()

These analysis were exposed through FastAPI APIs and consumed directly by the React Native app.

Seeding Large Logistics Datasets
To simulate real logistics operations, I generated synthetic shipment data using Faker and Mockaroo. This allowed me to create:

  • Thousands of shipments
  • Multiple drivers
  • Multiple warehouses

This was important because AI models require larger datasets for meaningful analysis.

Transitioning Into AI
The most exciting part of the project was the machine learning roadmap.

Route Optimization
Identify more efficient logistics routes.

Warehouse Efficiency Scoring
Analyze warehouse performance and congestion.

Demand Forecasting
Predict future shipment volumes.

Anomaly Detection
Detect unusual shipment behaviors.

Final Thoughts
This project taught me how backend systems and AI systems interact. What started as a logistics CRUD system evolved into something much bigger. I’ve been able to build a project that reflects both software engineering and AI engineering workflows. Links to the project can be found here : mobile, backend.

The portfolio math. When 30 small apps beat 1 big one.

The portfolio math. When 30 small apps beat 1 big one.

For a decade the indie hacker playbook stayed the same. Pick one product. Find a niche. Focus. Iterate. Sell. That advice fit 2014 perfectly. It started quietly going wrong in 2022, and by 2026 it is the wrong default for most solo operators, including a meaningful chunk of the ones the courses are still selling it to.

Eight solo founders crossed twenty thousand dollars a month in revenue between November 2025 and April 2026. The shape of how they got there is not the shape the courses describe. The shape is a portfolio. One person, many products, lots of small bets, no precious single hill to die on.

This article is the economic case for the portfolio shape, the math that determines whether it fits your situation, the kill rule that makes it work, and a working calculator you can paste into a spreadsheet this afternoon. By the end you will know whether you should be running one product or seven, and you will know exactly what number to track to decide if a given product belongs in the portfolio.

TL;DR

The choice When it wins
Single product, all-in High build cost, defensible moat, large addressable market, slow feedback cycles
Portfolio of 5 to 10 Medium build cost, fragmented attention, fast feedback cycles, you have any distribution
Portfolio of 20-plus Very low build cost, niche-of-niches, owned channel, willing to kill aggressively

The math behind that table is below.

1. The case for the portfolio in three numbers

The portfolio shape is not a fashion. It is a response to three measurable changes since 2014.

Number 1: build cost per product, in hours
  2014:  ~400 hours for a working SaaS with payments
  2020:  ~120 hours, same scope
  2026:   ~25 hours, same scope, including auth, payments, and a usable UI

Number 2: cost per useful signal, in product-attempts
  2014:  one attempt, run for 6 to 12 months, then maybe one more
  2026:  ten to thirty attempts, each run for 30 to 90 days

Number 3: average successful attempt rate, indie SaaS
  Published founder reports cluster around 1 in 8 to 1 in 15
  Conservative call: 1 in 10

Combine those three. In 2014 you got one shot per year. In 2026 you can take twenty shots per year. If one in ten shots becomes a paying product, the single-shot strategy gets you to a paying product roughly every decade. The twenty-shot strategy gets you to two paying products per year, on average.

This is the entire economic argument for the portfolio. It is not that portfolios are inherently better. It is that the cost of an attempt fell by an order of magnitude, and the strategy that matches the new cost is to take more attempts.

2. The actual revenue distribution inside a portfolio

The first thing to understand is that a portfolio does not produce uniform revenue. It produces a long tail.

Max, one of the eight founders from the writeup, makes $22K MRR across thirty apps. Average revenue per app: $733. That number is misleading. The real distribution probably looks like this:

   App rank | Estimated share of MRR | Estimated MRR
   ─────────┼───────────────────────┼──────────────
   App 1    |  35 to 50%            |  $7,700 to $11,000
   App 2    |  15 to 20%            |  $3,300 to $4,400
   App 3    |  10 to 15%            |  $2,200 to $3,300
   App 4-6  |  5 to 8% each         |  $1,100 to $1,760 each
   App 7-15 |  1 to 3% each         |  $220 to $660 each
   App 16+  |  near zero            |  rounding error

The distribution above is a power law, which is the same shape every portfolio of consumer or SMB SaaS products converges to. Pieter Levels has been transparent about this for years across his 12-plus product portfolio. A handful of products carry the revenue. The rest exist to feed the funnel and explore new niches.

If you find this depressing, the portfolio shape is not for you. The right reading is liberating: you do not need every product to win. You need to ship enough that one or two find the power law top.

3. The kill rule is the load-bearing piece

The thing that separates a working portfolio from a graveyard of half-finished SaaS projects is the kill rule. Without it the portfolio becomes a tax. Each product needs maintenance. Each product accumulates support tickets, dependency upgrades, expired domains, broken Stripe webhooks. A portfolio of unkilled losers will eat all your time.

The kill rule has three components.

Component 1: time horizon
  Pick a number, write it down, do not negotiate with yourself later.
  Reasonable defaults: 30 days for SaaS, 60 days for content products,
                       90 days for marketplaces.

Component 2: signal threshold
  Define the minimum signal that justifies keeping the product alive.
  Reasonable defaults: 3 paying customers, OR $50 MRR,
                       OR 100 active users with stickiness over 20%

Component 3: kill action
  Define exactly what "kill" means before you have to do it.
  Standard practice: archive the repo, sunset the domain,
                     refund any remaining subscribers, write the postmortem.

A working kill rule reads like a contract: “If this product has fewer than 3 paying customers 30 days after launch, I archive the repo, redirect the domain to my portfolio page, and write a one-page postmortem before starting the next product.”

The contract part matters. You will not want to kill the product. You will have spent 25 hours on it. You will have a tiny number of free users who like it. You will tell yourself that with one more feature it will take off. The kill rule is the version of you that wrote the contract overruling the version of you that is sentimental about the work.

The portfolio founders who succeed are not the ones with the best products. They are the ones with the strictest kill rules.

4. A working calculator

You can decide whether the portfolio shape fits your situation with this calculator. Paste it into a spreadsheet, fill in the inputs, read the recommendation.

INPUTS

  H = hours to ship a working version (including auth, payments, UI)
  S = your success rate per attempt (default 0.10 if unknown)
  D = distribution multiplier (1.0 if launching cold, 3.0 if you have any
      owned channel, 8.0 if you have a list of 5K-plus engaged followers)
  W = available hours per week
  K = your sentimental kill tax (in extra hours per failed product)

CALCULATIONS

  Attempts per year possible:
    A = (W * 50) / (H + K)

  Expected successful products per year:
    P = A * S * D

  Cost per successful product:
    C = (H + K) / (S * D)

DECISION RULES

  If P < 1, you cannot run a portfolio. Pick a single product.
  If 1 <= P < 3, run a small portfolio of 5 products. Be strict.
  If P >= 3, run an aggressive portfolio. Kill faster.

Example for a founder with 20 hours a week, 25-hour builds, no distribution, no kill tax:

A = (20 * 50) / (25 + 0) = 40 attempts per year possible
P = 40 * 0.10 * 1.0 = 4 expected successes per year
C = 25 / (0.10 * 1.0) = 250 hours per successful product

That founder should run an aggressive portfolio. Same founder, same hours, but with a 10K-follower X account that they have nurtured for two years:

A = (20 * 50) / 25 = 40 attempts per year
P = 40 * 0.10 * 8.0 = 32 expected successes per year (clip to feasibility)

The math goes silly fast when distribution is the multiplier, because distribution is the multiplier. The cap is realistically how many products one person can actually maintain at once, not how many will succeed.

5. When the single product still wins

The portfolio shape is not universal. Three cases where focusing on a single product is the right call, regardless of the math above:

Case 1: high build cost, defensible moat
  If your product needs 600 hours of engineering before the first
  customer can even use it, the attempt cost is too high to run
  a portfolio. Examples: developer infrastructure, a database, a
  language runtime, deep ML, hardware. Pick one. Commit.

Case 2: large total addressable market, slow feedback cycle
  If the buyer has a 6-month evaluation cycle (enterprise SaaS,
  regulated industries, government), the portfolio cannot give you
  enough signal per year. Pick one. Run a long sales cycle.

Case 3: brand-building motion
  If your goal is to become the founder of the thing (the next
  Stripe, the next Linear, the next Figma), the portfolio shape
  fights you. Investors, press, and senior hires want one story.
  Pick one. Tell the story.

If you are in any of these three cases, run the single product strategy and ignore the portfolio noise. If you are not, the math says you are leaving signal on the table by limiting yourself to one product.

6. The operating cadence that actually works

Founders who run successful portfolios converge on a similar weekly cadence:

Mondays:  triage the portfolio. Which products had movement? Which need
          a support reply? What is the metric I am tracking per product
          this week?

Tuesdays-Thursdays: build. Either ship a new product, ship a meaningful
          improvement to a top-3 revenue product, or kill a failing
          product per the contract.

Fridays:  distribution. Post about whatever shipped this week. Engage
          in the channel you own. Reply to comments. No new code.

Saturdays: rest.

Sundays:  one hour of metrics review. Update the portfolio dashboard.
          Decide what next week's primary focus is.

The discipline is in the constraint. You do not work on a product unless it appears in Monday’s triage. You do not start a new product mid-week. You do not skip Friday distribution because you are “behind on shipping.” The cadence is the moat.

7. The honest take

The portfolio shape is not a moral upgrade over the single product shape. It is a different shape of bet, with its own losing scenarios. The biggest one is brand. A founder with thirty products will never become the founder of one thing. The LinkedIn headline reads “Maker of stuff.” The Twitter bio reads as a list. The portfolio founder will not become the next Stripe.

That tradeoff suits a goal of freedom and revenue. The tradeoff fails a goal of building a category-defining company. Pick the goal honestly. The portfolio gets you out of the day job. The single product, if it works, gets you to the IPO.

The current decade rewards the portfolio shape more than the previous one did, because the cost of an attempt fell by an order of magnitude. The strategy that matches the new cost is to take more attempts. The kill rule turns those attempts into a long-term system instead of a graveyard.

Run the calculator. Be honest about your distribution. Pick your shape. Set the kill rule. Then start.

Question for the comments: how many products do you currently maintain, and what is your actual kill rule (not the one you wish you had)?

GDS K S · thegdsks.com · follow on X @thegdsks

The cheapest year of your life is the one where you killed three bad ideas instead of one good one.

What If Your Phone Addiction Was Actually Building an Empire? (A Game Dev Story)

What If Your Phone Addiction Was Actually Building an Empire? (A Game Dev Story)

We all know that feeling.

You pick up your phone. 30 minutes later, you’re three rabbit holes deep on TikTok, Instagram, Reddit. You’ve scrolled through hundreds of posts. Nothing’s changed. You haven’t learned anything. Your thumb just got a workout.

And you’re probably not the only one.

The average person spends 3 hours and 43 minutes per day on social media apps. That’s 53 days per year of your life. Gone.

So I built a game about it.

Meet Doomscroll 2077: The Idle Empire

Doomscroll 2077 is a cyberpunk idle clicker game where your phone addiction actually does build an empire.

You start with a smartphone. You scroll. Data flows in. AURA accumulates. You unlock neural implants, sponsor uplinks, black market nodes. Your empire grows silently in the background—even when you’re not playing.

It’s free. No download. No signup. Just play in your browser: doomscroll2077.netlify.app

Why I Built This

Idle games (also called incremental or clicker games) are fascinating from a design perspective. They’re basically the opposite of traditional games:

  • No skill required — there’s no way to fail
  • No time commitment — play for 10 seconds or 10 hours
  • Passive progress — your empire grows while you’re sleeping
  • Deep strategy — despite simplicity, decisions matter (what to upgrade first? when to prestige?)

But Doomscroll 2077 is more than just a fun time sink. It’s a commentary on our relationship with phones and social media.

When you’re scrolling mindlessly, you’re generating value for billion-dollar platforms: your data, your attention, your time. They’re mining you.

In Doomscroll 2077, you become the platform owner. You mine the data. You extract the AURA. The tables have turned.

The Game Loop

Here’s how it works:

  1. Scroll — tap the screen, earn DATA
  2. Upgrade — unlock neural nodes, sponsor connections, black market plugins
  3. Extract AURA — convert DATA into permanent multipliers
  4. Prestige — reset your empire for exponential growth
  5. Repeat — watch your numbers go brrr

Each decision shapes your playstyle. Aggressive? Save up for big upgrades. Passive? Let the background nodes farm DATA while you sleep. There’s no wrong path.

Built by One Person. In India.

This is a solo project. I designed it, coded it, designed the UI, composed music, and deployed it—all from India using open-source tools and platforms.

It’s a statement in itself: great games don’t need AAA studios or million-dollar budgets. They need ideas, execution, and a weird sense of humor.

Play It Now

🎮 Play Doomscroll 2077 in your browser

Or if you prefer to download it:
📥 Download on itch.io

It’s completely free and made with pure joy (and spite toward algorithms).

What’s Next?

I’m constantly adding features:

  • New prestige mechanics
  • Rare events and upgrades
  • Leaderboards for speedrunners
  • Deeper economy balancing

If you have ideas or feedback, let me know. Game dev is a conversation with players, not a monologue.

Built with: JavaScript, Canvas API, love, and too much coffee ☕
Theme: Cyberpunk 2077 meets social media dystopia
Status: Live and growing

Start your empire. Scroll to the top. 📱💀

⚖️ Case File 3.1: The Rubber Stamp Fraud

The Collaboration Cartel

The most common form of technical fraud in software engineering isn’t writing buggy code; it’s signing off on logic you haven’t actually read.

After leading engineering teams for years, I can tell you that the quickest way to destroy a team’s trust is to treat the Pull Request (PR) process as a mere formality. We have all seen it: a major logic change with zero review comments, approved in five minutes. This isn’t “supportive” teamwork; it is the Rubber Stamp Fraud, and it is professional negligence.

🛑 The Crime: The Ghost Review

Checking for syntax is a job for linting tools; checking for logic is a job for engineers.

  • The Scenario: A large PR arrives on Friday afternoon, containing critical updates to a billing service. The reviewer, desperate to get home, scans the diff, sees that the tests passed, and clicks “Approve.”
  • The Crime: Signing off on the change without thoroughly reviewing the business logic or the “thinking mistakes”.
  • The Brutality: The logic has a critical flaw: it accidentally processes payments on inactive accounts, leading to thousands of dollars in financial discrepancies. When asked why they approved it, the reviewer says, “It passed linting.” They can’t explain the logic because they never actually read it.
  • How to Avoid It: If you don’t have time to review the logic properly, do not approve it. Request a delay or ask for a partial review.
  • Brutal Habit to Adopt: The Hostile Code Witness. Review every PR as if the author is a hostile witness trying to sneak a bug past you. Every line of code is guilty of being suboptimal until proven otherwise.

“Verification, Not Vanity.”

🧱 The Crime: The “Supportive” Accomplice

Approving your friend’s flawed PR isn’t teamwork; it’s a career conspiracy.

  • The Scenario: Your colleague and friend is struggling to hit a sprint goal. They have built a microservice but broke the downstream services. They need your “LGTM” (Looks Good To Me) to get the code merged and satisfy the project manager.
  • The Crime: Prioritizing team harmony or friendship over the technical integrity of the system.
  • The Brutality: The change causes production failures, breaking the three services downstream. Your team is now the source of technical debt for other teams.
  • How to Avoid It: Separate the person from the product. Critique the code, not the engineer. Technical authority is built on consistent standards, not transactional favors.
  • Brutal Habit to Adopt: The Blast Radius Audit. Before every approval, you must explicitly ask the author: “What is the blast radius of this change? Have you verified the downstream impact?”

“Ship Integrity, Not Favors.”

🤖 The Crime: Automated Absolution (AI Collaboration)

Believing an AI’s PR summary over the actual code is technical archaeology on a ticking bomb.

  • The Scenario: You use an AI agent to generate the description for a complex Pull Request. The AI provides a confident, clean explanation of what the change does.
  • The Crime: As the reviewer, reading only the AI-generated context summary and accepting it as absolute truth without cross-referencing the actual source code.
  • The Brutality: The AI hallucinated a crucial part of the summary. The code itself fails the 30-Minute Test: it is so “clever” and complex that a new hire cannot grasp the core logic in half an hour. By ignoring the code, you merged a maintainability nightmare.
  • How to Avoid It: AI is great for creating summaries, but the code is the only true source of logic. Always use the code, not the explanation, as your final authority.
  • Brutal Habit to Adopt: The Code-First Mandate. Never read the PR description until you have scanned the diff. Form your own mental model first, then use the description only to see if the author’s intent matches their implementation.

“Source Code is Law.”

🛠️ Case File Takeaway: The 30-Minute Readability Test

If a new hire cannot look at your architecture or code and understand the core logic within 30 minutes, you have committed a crime against readability.

💡 Professional Tip: When starting a task, step away from the IDE. Design the project (or major code section) on paper first. List the core requirements, draw the data flow, and identify the “hard parts.” If your paper design requires a manual and a translator, it’s not “advanced”—it’s already broken.

📋 Cheat Sheet: The Collaboration Cartel

[The Rubber Stamp Fraud]

The Crime The Red Flag The Fix Mnemonic Brutal Habit to Adopt
Ghost Review Approved, zero logic feedback. Review logic, not just syntax. Verification, Not Vanity Hostile Code Witness
“Supportive” Accomplice “LGTM” as a friend’s favor. Ship integrity, separate people. Ship Integrity, Not Favors Blast Radius Audit
Automated Absolution Read AI summary, ignore diff. The diff is the only truth. Source Code is Law Code-First Mandate

Next Part: We move to Case Fle 3.2: The Silo Conspiracy, where we tackle the crimes of downstream breakage and holding codebases hostage.

What is the worst case of “Rubber Stamp” negligence you’ve witnessed in production?

💬 Let’s talk in the comments.

I Broke My Proxmox Home Lab with a GPU Passthrough – Here’s How I Fixed It

How a Kubernetes worker VM with a passed-through AMD GPU sent my entire home lab into an infinite crash loop — and the GRUB-level trick that saved it.

What I Was Trying to Do

My home lab runs on a Mini PC with Proxmox as the hypervisor. The setup hosts a mix of LXC containers and KVM virtual machines — Jellyfin for media, a Pi-hole, a Kubernetes cluster (k3s), Cloudflare tunnels, and a bunch of other self-hosted services.

I was upgrading the hardware configuration of VM 104 (k3s-vm-worker) — one of the worker nodes in my k3s Kubernetes cluster. The goal was straightforward: pass through the host’s AMD GPU directly into the VM so the Kubernetes worker could handle GPU-accelerated workloads.

In Proxmox, GPU passthrough (PCIe passthrough) requires a few things:

  • IOMMU enabled in BIOS (AMD-Vi for AMD platforms)
  • amd_iommu=on iommu=pt kernel parameters in GRUB
  • Machine type set to q35 (required for PCIe passthrough)
  • The PCI device was added as hostpci0 in the VM’s hardware config

I configured all of this. The hardware tab showed the PCI device (0000:e6:00, highlighted in orange — a warning sign I probably should have paid more attention to). I saved the config, felt good about it, and rebooted.

That was my mistake.

What Happened — The Crash Loop

The Mini PC came back up, Proxmox started loading, and then — nothing. The host became completely unresponsive. No web UI. No SSH. Nothing.

I power-cycled it. Same thing. It would power on, start booting, and crash before I could even get to the Proxmox web interface.

Here’s what was actually happening, which I only understood after I could finally get in and check the logs:

VM 104 had “Start at boot” set to Yes.

The moment Proxmox finished loading, it triggered a “Bulk Start VMs” task — its automatic process for starting all VMs flagged to auto-start. VM 104 was in that queue. The second that task reached VM 104, Proxmox tried to initialise the PCIe passthrough, the VM grabbed the GPU from the host, and the entire system crashed — hard.

I panicked and quickly got back into the web UI during a narrow window and changed “Start at boot” to No. Thought that would fix it.

It didn’t.

Proxmox had already queued the bulk start task the moment it turned on. Changing the setting mid-flight did nothing. The next boot, same crash. The boot after that, same crash.

I was locked in an auto-start crash loop with no way out through the web UI.

Understanding the Problem — Why It Was So Hard to Escape

This is the part that makes this specific failure mode so nasty:

Boot → Proxmox loads → "Bulk Start VMs" fires instantly →
VM 104 grabs GPU → Host kernel panics → System crashes →
Reboot → repeat forever

The window between “Proxmox is up enough to serve the web UI” and “VM 104 has already been queued and grabbed the GPU” was too small to intervene through normal means. Every time I managed to get in and change something, the crash had already been scheduled.

The only way out was to break the loop before Proxmox had any chance to load its VM management services at all — which means intervening at the GRUB level, before the operating system even finishes booting.

The Solution — Masking the VM Start Service at Boot

This required plugging a physical keyboard and monitor into the Mini PC. No SSH, no remote — physical access only.

Step 1: Stop the autoboot timer at GRUB

Power on the machine and watch the screen. The moment the blue Proxmox GRUB menu appears, press the Down Arrow key immediately. This stops the autoboot countdown timer and lets you stay at the GRUB menu.

Step 2: Edit the boot command

Highlight the top “Proxmox VE” entry and press e to edit the boot command. You’ll see the full kernel command line. Navigate to the end of the linux line.

Remove amd_iommu=on iommu=pt (these were the IOMMU parameters I’d added earlier that enabled the passthrough).

Then, at the very end of the linux line, add:

systemd.mask=pve-guests.service

So the line ends with:

ro quiet systemd.mask=pve-guests.service

Press Ctrl + X to boot with this modified command.

What systemd.mask=pve-guests.service actually does

pve-guests.service is the systemd unit responsible for the “Bulk Start VMs” behaviour in Proxmox. Masking it via the kernel command line at boot tells systemd to completely skip that service for this boot session only — it doesn’t persist across reboots.

The result:

  • ✅ Proxmox boots normally
  • ✅ Web UI comes up
  • ✅ Network is available
  • ✅ Zero VMs start automatically
  • ✅ Host does not crash

Step 3: Fix the VM config via web UI

Once safely in the web UI with no VMs running:

  1. VM 104 → Options → “Start at boot” → No
  2. VM 104 → Hardware → PCI Device (hostpci0) → Remove
  3. VM 104 → Hardware → Machine → change from q35 back to i440fx
  4. Reboot normally — don’t touch the GRUB menu this time

Plot Twist — The GPU Renamed Itself

After fixing the crash loop and getting the host stable, I started my LXC containers — CT 204 (jellyfin-arr) and CT 208 (linkwarden) — and hit a completely different error:

Error: Device /dev/dri/card0 ...

Both containers refused to start. I checked the DRI devices on the host:

ls -la /dev/dri/

Output:

crw-rw---- 1 root video  226,   1 May 13 13:51 card1
crw-rw---- 1 root render 226, 128 May 13 13:51 renderD128

The AMD GPU loaded perfectly (Kernel driver in use: amdgpu). But Linux had decided to name it card1 instead of card0.

This is a surprisingly common Linux behaviour: when you change BIOS IOMMU settings, or reboot without an HDMI cable connected to a GPU, the kernel can enumerate DRM devices in a different order and assign a different cardN number. The GPU is fine. The driver is fine. The device node just got a different name.

My LXC containers were hardcoded to pass through /dev/dri/card0. The fix was trivial once I understood it:

  1. CT 204 → Resources → Device (dev1) — change /dev/dri/card0 to /dev/dri/card1
  2. CT 208 → Resources → Device (dev1) — same change

Both containers started instantly. Hardware transcoding in Jellyfin came back up immediately.

Root Cause Analysis

Looking back, there were actually three separate issues stacked on top of each other:

# Issue Root Cause
1 Host crash loop VM with PCIe passthrough set to “Start at boot” — GPU grabbed before host could stabilise
2 Can’t escape via web UI pve-guests.service fires before web UI is interactive enough to intervene
3 LXC containers broken GPU renamed from card0 to card1 after IOMMU BIOS change

The orange 0000:e6:00 text in the VM Hardware tab was the visual warning I missed — in Proxmox, orange on a PCI device entry typically indicates the device may not be properly isolated in its IOMMU group, or that there’s a configuration issue worth investigating before saving and booting.

Lessons Learned

1. Never enable “Start at boot” on a VM with PCIe passthrough until you’ve verified the passthrough works correctly in a manual start first.

Test the VM with a manual start. Confirm the host stays stable. Confirm the VM boots correctly with the passed-through device. Only then enable auto-start.

2. systemd.mask=<service> at GRUB is your emergency brake.

Any time Proxmox is in a state where it crashes before you can intervene through the web UI, this technique gives you a clean boot with surgical control over what services start. pve-guests.service is the specific one to mask for VM auto-start loops.

3. GPU device node names are not stable across reboots in Linux.

/dev/dri/card0 is not guaranteed to be your GPU after every reboot, especially when BIOS settings change. Instead of hardcoding card0, consider:

  • Using udev rules to create a stable symlink to your specific GPU by PCI ID
  • Or checking ls /dev/dri/ after any BIOS/IOMMU change before starting dependent containers
# Find which card corresponds to your GPU by PCI ID
ls -la /dev/dri/by-path/
# or
lspci -k | grep -A 3 VGA

4. PCIe passthrough machine type matters — and q35 has implications.

q35 is required for proper PCIe passthrough but behaves differently from i440fx in several ways. Switching machine types mid-configuration without a working baseline is risky. Start with a known-good VM on i440fx, verify it’s stable, then migrate to q35 and add the PCI device.

5. Physical access to your home lab server is non-negotiable.

If you’re running a home lab with GPU passthrough or any kind of hardware-level config, have a keyboard and monitor you can plug in. SSH and the web UI are great until they’re not. The GRUB console saved this entire setup.

The Final State

After all of this:

  • ✅ Proxmox host boots cleanly and stays stable
  • ✅ All LXC containers (jellyfin-arr, linkwarden) running with GPU access on card1
  • ✅ k3s Kubernetes cluster (VMs 103, 104, 105) running normally
  • ✅ All other services (Plex, Pi-hole, Cloudflare, Pulse, Prowlarr, Pendela) unaffected
  • ⚠️ GPU passthrough into k3s-vm-worker: parked for now, to be re-approached correctly

The GPU passthrough into the Kubernetes worker is still on the roadmap — I’ll approach it differently: test in a throwaway VM first, verify IOMMU grouping, confirm stability with manual start, and only then wire it into the k3s node.

Quick Reference — Commands Used

# Check GPU device assignment on host
ls -la /dev/dri/
lspci -k | grep -A 3 -i vga

# Check IOMMU groups (run as root on Proxmox host)
for d in /sys/kernel/iommu_groups/*/devices/*; do
  n=${d#*/iommu_groups/*}; n=${n%%/*}
  printf 'IOMMU Group %s ' "$n"
  lspci -nns "${d##*/}"
done

# GRUB emergency boot (type this at the end of linux line in GRUB edit)
systemd.mask=pve-guests.service

# Find stable GPU device path by PCI address
ls -la /dev/dri/by-path/

Running a home lab means breaking things and learning from them. This one taught me more about Proxmox internals, systemd masking, and Linux DRM device enumeration than any documentation would have. Hope it saves someone else the same panic.

Feel free to drop questions in the comments — happy to help if you’re stuck in a similar loop.

The CVE That Wasn’t: Microsoft’s Azure Vulnerability Rejection and the Eroding Trust in Cloud Disclosure

TL;DR:

  • A security researcher discovered a critical cross-tenant access flaw in Microsoft Azure’s identity management layer, capable of exposing sensitive customer data across organizational boundaries — and provided full technical documentation with proof-of-concept code.
  • Microsoft’s Security Response Center (MSRC) rejected the submission as “by design,” classifying the vulnerable behavior as intended functionality rather than a security defect — and declined to issue a CVE identifier.
  • The rejection is inconsistent with Microsoft’s own historical precedent, as substantially similar cross-tenant vulnerabilities in Azure have received CVE assignments and security patches in the past.
  • This pattern — classify as “by design,” silently patch later — erodes the coordinated vulnerability disclosure ecosystem and leaves Azure customers unaware of active risks that attackers may already be exploiting.

What the Researcher Found

The vulnerability resided in Azure’s cross-tenant access architecture — the mechanisms governing how identities, resources, and permissions interact across organizational boundaries in shared cloud environments. Under specific conditions, an attacker could traverse tenant isolation boundaries to access resources belonging to other Azure customers. The attack path did not require exploiting a misconfiguration on the victim’s side; it leveraged behavior present in Azure’s own identity and access management plumbing.

The submission followed the gold standard: detailed technical write-up, step-by-step reproduction, working PoC code, and clear security impact analysis. From an engineering perspective, cross-tenant authorization bypasses are among the highest-severity cloud issues — they undermine the fundamental isolation primitive that the entire multi-tenancy model depends on.

The “By Design” Rejection

Microsoft’s formal response classified the reported behavior as intended design. The MSRC case was closed without a fix, CVE, or public advisory. This framing is increasingly contentious: every bug reclassified as a design choice is one fewer patch cycle and one less mark on the security track record. But a CVE provides a globally recognized identifier that security teams use to track, assess, and remediate risks. Without one, the finding struggles to propagate through enterprise vulnerability management pipelines.

Precedent Contradictions

Azure has issued CVEs for cross-tenant vulnerabilities in the past — including issues in Azure AD, shared key authentication flows, and resource management APIs. The architectural similarity between those acknowledged vulnerabilities and the current finding raises an uncomfortable question: what changed? The distinction between a code-level authorization bug and an architectural authorization gap may matter to engineers, but from the customer’s perspective it is academic. If Tenant A can read Tenant B’s storage, the taxonomy of the flaw matters less than the fact of the exposure.

The Silent Fix Pattern

Multiple documented instances exist where Microsoft rejected a security submission, then quietly addressed the underlying issue in a subsequent Azure update — without credit, CVE, or advisory. This pattern is corrosive: it denies researcher credit, denies customers actionable identifiers for risk assessment, and denies the security community data points for understanding the threat landscape. For engineering teams operating Azure workloads, a silent fix is functionally indistinguishable from an unpatched vulnerability until someone reverse-engineers the update.

Engineering Takeaways

Cross-tenant isolation must be treated as an untrusted boundary even when provider documentation asserts otherwise. Penetration tests should explicitly target tenant-boundary traversal, not just application-layer vulnerabilities. Vulnerability management programs that depend solely on CVE feeds should supplement them with direct monitoring of researcher disclosures. The shared responsibility model requires sharper definition — provider-side architectural flaws in tenant isolation are not the customer’s responsibility to detect or mitigate. The industry needs clearer norms around when “by design” is a valid technical assessment and when it is a deflection.

For the complete architectural breakdown — including the full anatomy of the cross-tenant authorization bypass, a detailed comparison with previously acknowledged Azure CVEs, and a deeper analysis of the silent-fix pattern and its implications for enterprise vulnerability management — read the full analysis at susiloharjo.web.id:

🔗 https://susiloharjo.web.id/the-cve-that-wasnt-microsofts-azure-vulnerability-rejection-and-the-eroding-trust-in-cloud-disclosure/

Related on Susiloharjo:

Lighthouse Attention: The Training-Time Hierarchy That Makes Quadratic Attention Practical Again

TL;DR:

  • 1.4–1.7× pretraining wall-clock speedup against dense SDPA at 32K–128K context — no inference overhead, no architectural changes.
  • Symmetric pyramid pooling compresses queries, keys, and values together — unlike every prior sparse method that only pools K/V — yielding an S²d attention call instead of NSd.
  • Two-stage training with a recoverability guarantee: Stage 1 trains with Lighthouse selection, Stage 2 recovers under dense SDPA — final loss beats the dense-from-scratch baseline.
  • Selection lives entirely outside the attention kernel, reusing stock FlashAttention on a contiguous gathered sub-sequence — no custom sparse kernels, no entangled selection logic.

Why Attention Gets Expensive — and Why Sparse Methods Haven’t Solved It

FlashAttention solved the memory problem. It did not solve the compute problem. Scaled dot-product attention still scales as Θ(N²) — double the context, quadruple the FLOPs. At 512K context on a single NVIDIA B200, dense attention forward+backward burns enormous compute. Frontier models targeting million-token windows need 32 B200 GPUs for attention alone.

Existing sparse attention methods — NSA, HISA, DSA, MoBA — share two design choices that cause problems for pretraining. First, they pool only keys and values, keeping queries at full resolution. The attention call stays O(NSd) — still linear in N. Second, they embed selection logic inside custom attention kernels, meaning teams cannot reuse optimized FlashAttention kernels. The hardest problem: a training-time sparsifier must produce weights that still work as a competent dense-attention model at inference. Most prior methods never test this.

The Lighthouse Approach: Symmetric Pooling + External Selection

Lighthouse makes two decisive departures. Queries, keys, and values are all pooled symmetrically into an L-level pyramid — turning the attention call from O(NSd) to O(S²d) where S ≪ N. At 512K context, the forward pass becomes 21× faster.

Selection sits entirely outside the attention kernel. A four-stage pipeline — pyramid pooling, parameter-free ℓ₂-norm scoring, chunked-bitonic top-K selection, and FlashAttention on the gathered sub-sequence — wraps around standard SDPA without modifying it. The top-K step is deliberately non-differentiable: gradients flow only through the gathered Q, K, V entries into the projection weights, teaching the model to produce values useful when selected rather than scores good at selecting.

The chunked-bitonic top-K produces stratified selection, not strict global top-K — preventing attention collapse onto a narrow span. The coarsest pyramid level is always retained in full, guaranteeing every position gets at least one contributor.

Recovery Works — and the Model Gets Better

The acid test is recoverability. A 530M Llama-3-style decoder was trained on C4 at 98K context with Lighthouse in 26 of 30 layers (the first two and last two stay dense). At 16,000 total steps (~50.3B tokens), three Lighthouse→dense split points were tested. At each resume, loss spikes transiently by 1.12–1.57 nats, then recovers within ~1,000–1,500 SDPA steps and crosses below the dense baseline.

By step 16,000, all Lighthouse runs reach final losses of 0.6980–0.7102 vs. the dense baseline’s 0.7237 — while using 22.5–27.0 wall-clock hours instead of 37.9. On Needle-in-a-Haystack retrieval (4K–96K context), Lighthouse with k=2048 matches or beats the dense baseline’s retrieval rate. Context parallelism scales cleanly to 1M tokens across 32 B200 GPUs with no kernel changes.

Engineering Takeaways

Lighthouse is not a universal accelerator. At short contexts, pyramid overhead dominates and it provides no benefit. At 32K+ tokens, it is a drop-in pretraining optimization: no architectural changes, no inference penalty, no custom sparse kernels to maintain. The two-stage recipe is essential — skipping Stage 2 recovery leaves the model unable to perform dense attention. The optimal configuration (L=3, p=4, k=1536, projection-norm scorer) is well-characterized. Lighthouse integrates with existing context-parallelism infrastructure without sparse-aware collectives.

The one clear limitation: it is training-only. Autoregressive decoding presents one query at a time, violating the all-queries-co-occur assumption. For teams whose bottleneck is pretraining throughput at long context — which describes most frontier-model efforts — Lighthouse is a proven, recoverable speedup with no strings attached.

For the complete architectural breakdown — including the four-stage pipeline internals, the chunked-bitonic top-K mechanism that prevents attention collapse, and the full ablation grid across pyramid depths, top-K budgets, and scorer types — read the full analysis at susiloharjo.web.id:

🔗 https://susiloharjo.web.id/lighthouse-attention-nous-research/

Related on Susiloharjo: