What I learned scraping 141 crypto cardholder agreements

On 3 February 2026, three unrelated crypto cards — CEX.IO Card, Trustee Plus, and IN1 — stopped processing payments on the same day. They had no parent in common. They were not hacked. None of the front-end brands had failed. The only thing they shared was a Polish payment-institution whose license had been revoked twelve days earlier by KNF.

That was the prompt to start a dataset. The question was simple: how many other crypto cards share an underlying issuer that almost no user has ever heard of? Answering it required reading roughly 141 cardholder agreements.

This post is about what that data collection actually looked like — the scraper choices, the failure modes, and what surprised me about the structure of “publicly available” legal documents on the web.

The architecture, in two paragraphs

Most crypto companies don’t directly issue payment cards. They rent the right to issue cards from a principal member of Visa or Mastercard. That principal — usually a small or mid-size bank or e-money institution — is the BIN sponsor. The six digits at the start of the card number identify them. The brand on the front of the card is a separate company, the program manager, which contracts with both the sponsor and the user.

Three layers, one of them visible. The sponsor is the layer the regulator can actually shut down. When the regulator does, every program manager on that sponsor’s BIN goes dark on the same day. From the user’s perspective there is no warning, because the user never signed up with the sponsor.

If that sounds familiar — Stripe acquired Bridge in 2025, Coinbase Card runs on Pathward (not Marqeta, which is just the processor), Gnosis Pay runs on Monavate — it is the same pattern at scale.

The scrape

The first plan was naive: a Playwright job that visited each card’s /legal or /terms URL, extracted text, and ran a regex for the phrase “issued by [BANK NAME]”. This worked for about a third of the dataset.

The other two-thirds failed in interesting ways:

  1. Cardholder agreement is a PDF generated only after KYC. About a dozen cards. The static T&C is a marketing summary; the legally binding agreement is generated at application time with a Lambda. You can’t fetch it.

  2. Sponsor name is in an appendix, not the first paragraph. A regex that scans the first 500 words misses it. Some cardholder agreements bury “issued by ___” inside a chargeback procedures section, sometimes thousands of words in.

  3. Sponsor disclosure was deleted. A handful of cards used to name their sponsor and quietly removed it after the Union54 BIN suspension in 2022. The Wayback Machine still has the old version. The current page doesn’t.

  4. The page is rendered client-side via a wallet SDK that won’t run in headless Chrome. Two cards. Solved by switching to a real Chrome instance with the wallet extension pre-installed.

  5. The card’s website doesn’t include a cardholder agreement at all. Around 25 cards. The agreement exists somewhere — there must be a paper trail because Visa or Mastercard requires one — but the public-facing site doesn’t link to it.

For (5), the only reliable signal is the BIN itself. If you can find a forum post or a press release with someone’s card number prefix, you can look up which member that prefix is registered to and infer the sponsor. The signal is noisy, but it’s better than nothing.

What ended up in the dataset

After two passes (one scraped, one manual cross-check), each card got one of four confidence labels:

  • HIGH (~79 cards): sponsor name verbatim from a publicly fetched T&C, on a date recorded with the row.
  • MEDIUM (~34 cards): sponsor named in an older snapshot, press release, or regulator filing — but the current public page doesn’t repeat it.
  • CIRCUMSTANTIAL (~25 cards): inferred from program-manager naming or industry partnerships. Treated as upper-bound estimate, not fact.
  • UNKNOWN (~3 cards): best guess, flagged for follow-up.

If you’ve built data products before, this part will be familiar. The interesting wrinkle is that the legal disclosure regime varies wildly by jurisdiction. US and EU cards almost always name the sponsor verbatim. APAC programs frequently do not. African and LatAm cards have actively removed the disclosure since 2022, because Union54’s BIN suspension that year created a contagion risk — if the regulator suspends your sponsor for someone else’s fraud, you want to keep your customer association with the sponsor quiet.

That asymmetry — disclosure norms diverging across regions — is itself a structural fact about the market. It is not a dataset cleanliness problem.

What the data shows once you have it

Globally, the Herfindahl-Hirschman Index across all 141 cards is around 400 to 500. Below the US DOJ threshold for “unconcentrated.” That number is misleading. Once you split by region and product type — which is the actual choice a user faces when picking a card — the picture inverts.

US self-custody stablecoin cards: HHI around 5,000 to 6,300 depending on how you count circumstantial attribution. Two banks (Third National in Tennessee, Lead Bank in Missouri) anchor roughly two-thirds of issuance. EU/UK self-custody: even worse — a single sponsor (Monavate, owned via Baanx since 1 May 2026 by Exodus) anchors most of the segment.

If you want to look at the per-card data, the methodology, or the per-row source URLs, the dataset is at sweepbase.net/dataset and the full write-up of the concentration findings is at sweepbase.net/research/bin-sponsor-concentration-2026. Both are CC-BY for academic and journalistic use.

What I’d build differently next time

Three concrete things, for anyone trying to do this kind of dataset:

  1. Don’t trust a single fetch. Spot-audit on the day of publication. Of 32 cards I re-checked on 16 May 2026, only 14 were verbatim re-verifiable. The rest had been edited since the original scrape. The dataset now schedules quarterly re-fetches.

  2. Track which jurisdiction’s regulator can shut each sponsor down. Most public BIN datasets are jurisdiction-blind. For risk analysis, that’s a critical missing column. KNF can shut down a Polish sponsor in twelve days. The FCA cannot. Knowing which is which changes the risk-weighting of each card.

  3. Distinguish sponsor from processor from program manager. The single most common error in casual coverage of crypto cards — repeated in trade press for years — is calling Marqeta the “issuer” of Coinbase Card. Marqeta is the processor. The actual sponsor (Pathward) doesn’t appear in 95% of articles about the card. Different roles, different regulators, different failure modes.

Closing

Most “crypto card competition” coverage treats the front-of-card brands as substitutable when, behind the scenes, two different brands are often two skins on the same regulated entity. That doesn’t matter — until the regulator pulls the sponsor’s license, and three programs go dark on a Tuesday.

The dataset is open. Corrections welcome.

Originally published on Sweepbase Research. I run Sweepbase, an independent crypto-card comparison and research project tracking 141 active cards across regions, networks, and BIN sponsors.

Google I/O Review (1/5) — Gemini 3.5 ‘Flash’ Costs 15x More Than Flash 2.0. It’s Pro in Disguise

Gemini 3.5 “Flash” Costs 15x More Than Flash 2.0 — It’s Pro in Disguise

Google I/O 2026 Review — Part 1 of 5

The keynote crowd cheered. Sundar Pichai announced that Gemini 3.5 Flash outperforms Gemini 3.1 Pro on multiple benchmarks. The narrative was clean: the lightweight, cheap model just beat the flagship. The start of “the agentic Gemini era.”

Then I opened the pricing page.

Flash and Pro Are Neighbors Now

Model Input (per 1M tokens) Output (per 1M tokens)
Gemini 3.5 Flash $1.50 $9.00
Gemini 3.1 Pro $2.00 $12.00

Source: Google AI pricing, accessed 2026-05-19.

Flash at $1.50/$9.00. Pro at $2.00/$12.00. That is a 25% gap on input, 25% on output. These are not different tiers. They are neighbors. Two years ago, Flash cost a fraction of Pro. Now they share the same block.

If someone showed you these two price points without labels, you would guess they are variants of the same model class. You would be right.

How Flash Got Here: Three Generations of Price Creep

Model Input (per 1M tokens) Output (per 1M tokens) vs 2.0 Flash (Input) vs 2.0 Flash (Output)
1.5 Flash $0.075 $0.30 0.75x 0.75x
2.0 Flash $0.10 $0.40 1x (baseline) 1x (baseline)
2.5 Flash $0.30 $2.50 3x 6.25x
3.0 Flash $0.50 $3.00 5x 7.5x
3.5 Flash $1.50 $9.00 15x 22.5x

Source: Google AI pricing. All prices are standard (non-batch) per 1M tokens.

From 2.0 Flash to 3.5 Flash: input price rose 15x ($0.10 to $1.50). Output price rose 22.5x ($0.40 to $9.00). A model called “Flash” now costs fifteen times what Flash cost three generations ago.

The trajectory is clear. Flash did not stay in the lightweight lane. It grew into the price range that Pro used to occupy.

The Name Didn’t Change. The Economics Did.

Here is what I think actually happened: Google shipped Pro-level performance and put the Flash label on it.

The benchmarks are real. Flash 3.5 does outperform Pro 3.1 on the metrics Google showed. But outperforming Pro while costing nearly the same as Pro is not “the cheap model won.” It is “the expensive model got a new name.”

Think about it from Google’s side. If they had called it Pro 3.5 at $1.50/$9.00, the story would be: “Google cut Pro pricing by 25%.” Accurate, useful, but not a keynote moment. By calling it Flash, the story becomes: “Flash beat Pro!” That is a keynote moment. Same product economics, different narrative.

Pichai himself leaned into the framing. He used the word “tokenmaxxing” during the keynote — more tokens, more context, more throughput. Some out there might call this tokenmaxxing, he said. The naming is part of that narrative. Flash sounds lightweight and affordable. The pricing page tells a different story.

So Is This Bad? Not Exactly.

I want to be fair. The absolute price matters more than the brand name.

Pro-level performance at $1.50/$9.00 is genuinely useful. Consider an agent workload — a customer support bot handling 50,000 conversations per day. At legacy Pro pricing ($2.00/$12.00), the daily output token cost for, say, 500 tokens per response is:

50,000 conversations x 500 output tokens = 25M output tokens/day
At Pro 3.1: 25 x $12.00 = $300/day
At Flash 3.5: 25 x $9.00 = $225/day

That is $75/day saved, or roughly $2,250/month — with the same or better benchmark performance. For agent-heavy workloads running at scale, this price point opens real economic headroom.

The win is not that “Flash beat Pro.” The win is that Pro-grade inference got 25% cheaper. That is a quieter story, but a more honest one.

Benchmarks vs. Production: The Usual Caveat

One thing the keynote did not cover: benchmark performance and production performance are different conversations. Benchmarks test isolated capabilities — reasoning, coding, knowledge retrieval — under controlled conditions. Production workloads add latency variance, context window pressure, tool-call chains, and failure modes that benchmarks do not measure.

I have not tested Flash 3.5 in production yet. Nobody outside Google has had enough time to. If you are making infrastructure decisions based on the keynote benchmarks alone, you are making them on incomplete data. Wait for the community benchmarks. Wait for your own evals.

Gemma 4: A Quick Note from Local Testing

On a related note — I have been running Gemma 4 (2.3B) locally for on-device-llm-wiki, a zero-cost, fully offline knowledge engine. In our internal reasoning benchmark across on-device and cloud models, Gemma 4 scored 66/85 — outperforming Granite 3.4B (52), Qwen3 4B (28), and SmolLM2 1.7B (35). For reference, Claude Haiku 4.5 scored 76. A free, local 2B model reaching 87% of a commercial cloud model’s reasoning score — while beating a 4B competitor by more than 2x — is not incremental. It is a generational leap.

If Flash 3.5 carries the same generational improvement at cloud scale, the performance claims are plausible. Gemma is the open-weight sibling of the Gemini family, and quality gains in one tend to reflect in the other. But plausible is not confirmed — that requires production testing, not keynote slides.

What I Think You Should Do

  1. Read the pricing page, not the keynote. The pricing page is the source of truth. Marketing narratives are not.

  2. Run your own evals. If you are considering Flash 3.5 for production, test it on your workloads. Benchmark suites test what benchmark suites test.

  3. Compare to the actual competition. Flash 3.5 at $1.50/$9.00 competes with Claude Sonnet 4 ($3/$15), GPT-4.1 ($2/$8), and other mid-to-high tier models. Compare apples to apples at the price point, not at the brand name.

  4. Track the trajectory. Flash went from $0.10/$0.40 to $1.50/$9.00 in three generations. If the pattern holds, Flash 4.0 will cost what Pro costs today. Plan accordingly.

The Bottom Line

Google told a story about the cheap model beating the expensive one. The pricing page tells a story about the expensive model getting a cheaper name. Both stories have truth in them. The benchmarks are real. The price convergence is real. Which story matters more depends on what you are building.

For me, the useful takeaway is simpler: Pro-level performance is now available at $1.50/$9.00. That is good for anyone running agents at scale. Just do not call it cheap — it is 15x more expensive than the Flash you remember.

This is Part 1 of a 5-part Google I/O 2026 review series. Next up: Managed Agents API — serverless agents arrive, but so does GCP lock-in.

If you have tested Flash 3.5 against Pro on your own workloads, I would like to hear the numbers. Drop a comment or find me on GitHub.

Sources:

한국어 번역

Gemini 3.5 “Flash”는 Flash 2.0보다 15배 비싸다 — 사실 Pro의 변장이다

Google I/O 2026 리뷰 — Part 1 of 5

키노트 객석이 환호했다. 순다르 피차이가 Gemini 3.5 Flash가 여러 벤치마크에서 3.1 Pro를 능가한다고 발표했다. 서사는 깔끔했다: 가볍고 저렴한 모델이 플래그십을 이겼다. “에이전틱 Gemini 시대”의 시작.

그런데 가격표를 열어봤다.

Flash와 Pro가 이제 이웃이다

모델 입력 (1M 토큰당) 출력 (1M 토큰당)
Gemini 3.5 Flash $1.50 $9.00
Gemini 3.1 Pro $2.00 $12.00

출처: Google AI 가격 페이지, 2026-05-19 확인.

Flash $1.50/$9.00. Pro $2.00/$12.00. 입력 차이 25%, 출력 차이 25%. 다른 티어가 아니다. 이웃이다. 2년 전만 해도 Flash는 Pro의 몇 분의 1이었다. 이제 같은 블록에 산다.

라벨 없이 이 두 가격만 보여주면, 같은 모델 클래스의 변형이라고 추측할 것이다. 맞는 추측이다.

Flash가 여기까지 온 과정: 3세대 가격 상승

모델 입력 (1M 토큰당) 출력 (1M 토큰당) 2.0 대비 입력 2.0 대비 출력
1.5 Flash $0.075 $0.30 0.75배 0.75배
2.0 Flash $0.10 $0.40 1배 (기준) 1배 (기준)
2.5 Flash $0.30 $2.50 3배 6.25배
3.0 Flash $0.50 $3.00 5배 7.5배
3.5 Flash $1.50 $9.00 15배 22.5배

출처: Google AI 가격 페이지. 모든 가격은 표준(비배치) 1M 토큰 기준.

2.0 Flash에서 3.5 Flash까지: 입력 가격 15배 상승 ($0.10 → $1.50). 출력 가격 22.5배 상승 ($0.40 → $9.00). “Flash”라는 이름을 달고 가격이 3세대 만에 15배 올랐다.

궤적이 명확하다. Flash는 경량 레인에 머물지 않았다. Pro가 차지하던 가격대로 올라왔다.

이름은 안 바뀌었다. 경제학이 바뀌었다.

실제로 일어난 일은 이렇다고 본다: 구글이 Pro급 성능을 만들어서 Flash 라벨을 붙였다.

벤치마크는 진짜다. Flash 3.5가 구글이 보여준 지표에서 Pro 3.1을 실제로 능가한다. 하지만 Pro를 능가하면서 가격이 Pro와 거의 같다면, 그건 “싼 모델이 이겼다”가 아니다. “비싼 모델이 새 이름을 얻었다”이다.

구글 입장에서 생각해보자. 이걸 Pro 3.5라고 부르고 $1.50/$9.00에 내놨다면, 스토리는 “구글이 Pro 가격을 25% 내렸다”가 된다. 정확하고, 유용하지만, 키노트 순간은 아니다. Flash라고 부르면 “Flash가 Pro를 이겼다!”가 된다. 이건 키노트 순간이다. 같은 제품 경제, 다른 서사.

피차이 본인이 이 프레이밍을 밀었다. 키노트에서 “tokenmaxxing”이라는 단어를 썼다 — 토큰 더, 컨텍스트 더, 처리량 더. “some out there might call this tokenmaxxing”이라고 했다. 네이밍도 그 서사의 일부다. Flash는 가볍고 저렴하게 들린다. 가격표는 다른 이야기를 한다.

그래서 나쁜 건가? 꼭 그렇진 않다.

공정하게 말하고 싶다. 브랜드 이름보다 절대 가격이 중요하다.

Pro급 성능이 $1.50/$9.00에 나온다는 건 진짜 유용하다. 에이전트 워크로드를 생각해보자 — 하루 5만 건 대화를 처리하는 고객지원 봇. 레거시 Pro 가격 ($2.00/$12.00)에서 응답당 500 토큰 출력이라메:

5만 대화 x 500 출력 토큰 = 일 2,500만 출력 토큰
Pro 3.1: 25 x $12.00 = 일 $300
Flash 3.5: 25 x $9.00 = 일 $225

일 $75 절감, 월 앹 $2,250 — 같거나 더 나은 벤치마크 성능으로. 대규모 에이전트 워크로드에서 이 가격대는 실질적 경제적 여유를 만든다.

이긴 건 “Flash가 Pro를 이겼다”가 아니다. 이긴 건 Pro급 추론이 25% 싸졌다는 것이다. 더 조용한 이야기지만, 더 정직한 이야기다.

벤치마크 vs 프로덕션: 늘 있는 주의사항

키노트가 다루지 않은 것: 벤치마크 성능과 프로덕션 성능은 다른 대화다. 벤치마크는 통제된 조건에서 격리된 능력을 테스트한다 — 추론, 코딩, 지식 검색. 프로덕션 워크로드는 레이턴시 분산, 컨텍스트 윈도우 압박, 도구 호출 체인, 벤치마크가 측정하지 않는 실패 모드를 더한다.

나는 아직 Flash 3.5를 프로덕션에서 테스트하지 못했다. 구글 외부에서 충분한 시간을 가진 사람은 아무도 없다. 키노트 벤치마크만으로 인프라 결정을 내린다면, 불완전한 데이터로 결정하는 것이다. 커뮤니티 벤치마크를 기다려라. 자체 평가를 기다려라.

Gemma 4: 로컬 테스트 짧은 메모

관련해서 — 나는 Gemma 4 (2.3B)를 on-device-llm-wiki(https://github.com/ww-w-ai/on-device-llm-wiki)에서 로컬로 돌리고 있다. 비용 제로, 완전 오프라인 지식 엔진이다. 자체 온디바이스 + 클라우드 모델 대상 추론 벤치마크에서 Gemma 4는 85점 만점에 66점 — Granite 3.4B (52), Qwen3 4B (28), SmolLM2 1.7B (35)를 압도했다. 참고로 Claude Haiku 4.5는 76점이다. 무료 로컬 2B 모델이 상용 클라우드 모델 추론 점수의 87%에 도달하면서 — 4B 경쟁 모델을 2배 이상 이기는 건 점진적 개선이 아니다. 세대적 도약이다.

Flash 3.5가 같은 세대적 개선을 클라우드 스케일로 가져온다면, 성능 주장은 타당하다. Gemma는 Gemini 패밀리의 오픈 웨이트 형제이고, 한쪽의 품질 향상은 다른 쪽에 반영되는 경향이 있다. 하지만 타당하다와 확인됐다는 다르다 — 그건 프로덕션 테스트가 필요하지, 키노트 슬라이드가 아니다.

내가 생각하는 해야 할 것

  1. 키노트가 아니라 가격표를 읽어라. 가격 페이지가 진실의 원천이다. 마케팅 서사는 아니다.

  2. 자체 평가를 돌려라. Flash 3.5를 프로덕션에 고려 중이라면, 본인 워크로드에서 테스트하라. 벤치마크 스위트는 벤치마크 스위트가 테스트하는 것을 테스트한다.

  3. 실제 경쟁 제품과 비교하라. Flash 3.5 $1.50/$9.00은 Claude Sonnet 4 ($3/$15), GPT-4.1 ($2/$8) 등 중~상위 티어 모델과 경쟁한다. 브랜드 이름이 아니라 가격대에서 같은 것끼리 비교하라.

  4. 궤적을 추적하라. Flash가 3세대 만에 $0.10/$0.40에서 $1.50/$9.00으로 갔다. 패턴이 유지되면, Flash 4.0은 오늘의 Pro 가격이 될 것이다. 그에 맞게 계획하라.

결론

구글은 싼 모델이 비싼 모델을 이겼다는 이야기를 했다. 가격표는 비싼 모델이 싼 이름을 얻었다는 이야기를 한다. 두 이야기 모두 진실이 있다. 벤치마크는 진짜다. 가격 수렴도 진짜다. 어떤 이야기가 더 중요한지는 무엇을 만들고 있느냐에 달렸다.

나에게 유용한 결론은 더 단순하다: Pro급 성능이 이제 $1.50/$9.00에 있다. 대규모 에이전트 운영에 좋은 일이다. 다만 싸다고 부르지는 마라 — 당신이 기억하는 Flash보다 15배 비싸다.

이것은 Google I/O 2026 리뷰 5편 시리즈의 Part 1입니다. 다음: Managed Agents API — 서버리스 에이전트가 도착했다, GCP 락인과 함께.

Flash 3.5를 본인 워크로드에서 Pro와 비교 테스트해보셨다메, 수치를 듣고 싶습니다. 댓글이나 GitHub에서 찾아주세요.

Inspector.dev (Neuron), Laravel AI SDK, and Prism PHP: A Practical Comparison for Laravel Developers

The PHP and Laravel ecosystem has had a quiet revolution in the past year. Where once you had to piece together raw HTTP calls to OpenAI’s API and write your own retry logic, token counting, and provider-switching abstractions, you now have not one, not two, but three serious contenders for AI integration in your Laravel apps.

I’ve spent the last several months evaluating all three in real projects — and I want to give you the honest, side-by-side comparison I wish I’d had when I started.

The three tools in question are:

  • Inspector.dev / Neuron AI — a PHP-agnostic agentic framework backed by the Inspector monitoring platform
  • Laravel AI SDK (laravel/ai) — Taylor Otwell’s official, first-party SDK that shipped production-stable with Laravel 13
  • Prism PHP (echolabsdev/prism) — the community-built Laravel package by EchoLabs that’s been quietly filling this gap since before the first party entered the room

Let’s dig in.

The Lay of the Land

Before I compare them, it’s worth being clear about what each one is trying to do, because they don’t all occupy the same niche — and that’s the most important thing to understand.

Prism PHP is a unified LLM integration layer for Laravel. You pick a provider (OpenAI, Anthropic, Gemini, Ollama, Mistral), fire off a fluent query, get a structured response. Think of it as a clean, opinionated HTTP abstraction over multiple AI providers. It’s not an agent framework. It’s not a monitoring tool. It’s a very good way to talk to LLMs from Laravel.

Laravel AI SDK is Laravel’s official answer to that same problem, but with significantly broader scope. It handles text generation, image generation, audio transcription, embeddings, vector stores, structured output, RAG, and first-class Agent classes — all through a single, framework-native package. It shipped in beta with Laravel 12 and became production-stable in Laravel 13 (March 2026).

Inspector.dev / Neuron AI is a different animal entirely. Neuron is an agentic PHP framework — PHP-agnostic, not just Laravel-native — built by the team at Inspector.dev. Its ambition is to bring LangChain/LlamaIndex-style agentic orchestration to the PHP ecosystem. It comes with built-in observability through Inspector, which is its killer feature. You can monitor every LLM call, tool invocation, and agent step in real time.

With that framing in mind, let’s compare across dimensions that actually matter.

1. Provider Support

All three tools take a multi-provider stance, but they differ in breadth and maturity.

Prism PHP supports OpenAI, Anthropic, Gemini, Ollama, Mistral, and Groq. The interface is consistent across all of them — you switch providers by changing a single enum value. No other code changes required. This has been battle-tested in production by the community for over a year.

Laravel AI SDK supports OpenAI, Anthropic, Gemini, Groq, xAI, and ElevenLabs out of the box, with more planned. The configuration lives in config/ai.php and aligns with Laravel’s familiar patterns. Taylor’s team has also built smart fallbacks — if a provider hits a rate limit or goes down, the SDK can automatically fail over to another. That’s genuinely impressive and not something you get out of the box with the other two.

Neuron AI is similarly provider-agnostic. It uses a common AIProviderInterface for all LLM calls, so switching from Anthropic to OpenAI to Gemini is a one-line change. The framework-agnostic design means the provider layer works the same whether you’re on Laravel, Symfony, WordPress, or vanilla PHP.

Winner: Laravel AI SDK for the automatic failover feature. Runner-up: Prism, which has the most mature multi-provider support in the wild.

2. Developer Experience

This is where they diverge most sharply.

Prism PHP has the best fluent API for raw LLM calls. Here’s what a basic Anthropic call looks like:

use PrismPrismFacadesPrism;
use PrismPrismEnumsProvider;

$response = Prism::text()
    ->using(Provider::Anthropic, 'claude-3-7-sonnet-latest')
    ->withSystemPrompt(view('prompts.system'))
    ->withPrompt('Explain the Atomic Query Construction pattern.')
    ->asText();

echo $response->text;

It’s clean. It’s chainable. It feels Laravel-native even though it predates the official SDK. Testing utilities, response faking, and assertion helpers are included.

Laravel AI SDK introduces the concept of Agent classes — dedicated PHP classes that encapsulate your prompts, instructions, tools, and conversation history:

php artisan make:agent SupportAgent
class SupportAgent extends Agent
{
    protected function instructions(): string
    {
        return 'You are a helpful customer support agent...';
    }

    protected function tools(): array
    {
        return [new FetchOrderTool(), new RefundTool()];
    }
}

// Usage:
$agent = new SupportAgent();
$response = $agent->prompt('Where is my order?');

This is a more structured, testable pattern than raw LLM calls. It aligns with how Laravel organizes everything else — you get an Artisan command, a clear class contract, and built-in conversation storage backed by database migrations the SDK publishes for you.

Neuron AI takes the Agent pattern the furthest. You extend a base Agent class, define your provider, instructions, and tools, and get a full agentic system including RAG, multi-agent orchestration, workflow graphs with human-in-the-loop checkpoints, and streaming — all in PHP:

use NeuronAIAgentAgent;
use NeuronAIAgentSystemPrompt;
use NeuronAIProvidersAnthropicAnthropic;

class CustomerServiceAgent extends Agent
{
    protected function provider(): AIProviderInterface
    {
        return new Anthropic(config('services.anthropic.key'), 'claude-4-5-sonnet');
    }

    public function instructions(): string
    {
        return new SystemPrompt(
            background: ['You are a helpful customer service assistant'],
            steps: ['Verify order details before processing refunds'],
            output: ['Always confirm actions with the customer']
        );
    }
}

The Neuron approach is the most capable, but also has the steepest learning curve. It’s clearly inspired by LangChain and LlamaIndex — those familiar with Python-side agentic development will feel at home.

Winner: Laravel AI SDK for the best balance of structure and familiarity. Prism wins for simple LLM integration with minimal boilerplate. Neuron wins for complex agentic workloads.

3. Agentic Capabilities

If you’re building simple chat features or AI-powered text generation, all three will serve you well. But if you’re building autonomous agents, multi-step workflows, or RAG systems, the picture changes quickly.

Prism PHP is honest about what it is: a text/LLM integration layer. It’s not trying to be an agent framework. It handles tool calling with a clean API, supports structured output, and manages multi-turn conversations — but orchestrating multi-agent workflows is outside its design scope.

Laravel AI SDK is building toward agents. The Agent class system, structured output, RAG support through vector stores, and tool calling are all first-class. The SDK also ships with built-in conversation storage (the agent_conversations and agent_conversation_messages tables), so maintaining chat context across sessions requires zero custom infrastructure. This is a huge win for typical SaaS applications. However, the SDK is still young, and the more advanced agentic patterns (workflow graphs, human-in-the-loop, event-driven multi-agent coordination) aren’t yet in the box.

Neuron AI is the most complete agentic framework in the PHP ecosystem, full stop. It gives you workflow orchestration with event-driven architecture, streaming, human-in-the-loop checkpoints, agent-to-agent communication, vision capabilities, and a composable toolkit system. Neuron V2 rewrote the workflow system from scratch around event-driven patterns, making complex multi-step agent workflows practical and testable in PHP — something that was previously only achievable in Python-land.

Winner: Neuron AI for serious agentic applications. Laravel AI SDK for teams that need a structured starting point with room to grow.

4. Observability and Debugging

This is where Inspector.dev / Neuron has a decisive, built-in advantage.

Prism PHP has no built-in observability. You’re relying on whatever logging and monitoring you’ve wired up yourself — Laravel Telescope, your own query observers, or an external APM. For simple use cases this is fine. For complex pipelines, it’s a gap.

Laravel AI SDK fires events that you can listen to, which gives you hooks for custom logging and monitoring. But there’s no first-party dashboard or trace timeline out of the box. You can build it, but you have to build it.

Neuron AI + Inspector.dev is in a different league here. Because Neuron was built by the Inspector team, observability is first-class — not bolted on. Setting one environment variable is all it takes:

INSPECTOR_INGESTION_KEY=your_key_here

After that, every agent execution — every LLM call, every tool invocation, every workflow step — shows up as a trace in the Inspector dashboard. You can see execution timelines, identify which step in a complex workflow failed, measure token usage per step, and debug non-deterministic agent behaviour in a way that is simply not possible with logging alone.

For production AI agents, this observability is not a nice-to-have. It’s essential. Agentic applications are probabilistic — same input does not always equal same output. Without a trace-level view of execution, debugging becomes a nightmare.

Winner: Neuron AI / Inspector.dev, by a wide margin, for production agentic workloads.

5. Ecosystem Fit

Prism PHP is Laravel-only, but it’s deeply Laravel-native. It uses the Laravel HTTP client internally, integrates with Laravel’s service container, and follows all of Laravel’s conventions. If you’re a Laravel shop that doesn’t care about PHP framework portability, Prism’s Laravel-native ergonomics are hard to beat.

Laravel AI SDK is also Laravel-only, but it’s first-party Laravel — which means long-term support guarantees, documentation that lives at laravel.com, and tight integration with future Laravel features. For teams that want to bet on the framework’s official direction, this is the safest choice.

Neuron AI is deliberately framework-agnostic. Whether you’re on Laravel, Symfony, WordPress, or vanilla PHP, Neuron integrates without friction. This is a conscious design decision to grow the PHP ecosystem broadly rather than fragment it by framework. For shops running mixed codebases — say, a Laravel SaaS and a Symfony backend — Neuron lets you share agent implementations across both. This cross-framework portability is genuinely rare in the PHP ecosystem.

Winner: Laravel AI SDK for pure Laravel shops. Neuron AI for teams with mixed PHP codebases or long-term portability concerns.

6. Scope and Multimodality

Prism PHP: Text generation, embeddings, image input (multi-modal), tool calling, structured output. Clean and focused.

Laravel AI SDK: Text generation, image generation (DALL-E, Gemini), audio transcription (Whisper), embeddings, vector stores, RAG, file search, web search, reranking. Possibly the broadest scope of the three by design.

Neuron AI: Text generation, tool calling, RAG, vector store integration, vision/image input, multi-agent orchestration, workflow graphs. Focused on the agentic surface area rather than generative media.

If you need to generate images or transcribe audio directly through the same package, the Laravel AI SDK is the only one that covers you today without additional packages.

The Honest Recommendation

After working with all three in real projects, here’s how I think about choosing between them:

Choose Prism PHP if you want to add LLM-powered features (text generation, tool calling, structured output) to a Laravel app as quickly as possible with minimal architecture overhead. It’s the most mature community solution, the most ergonomic for straightforward use cases, and it’s not going anywhere. If all you need is a clean way to talk to multiple LLM providers from Laravel, Prism is the right tool.

Choose Laravel AI SDK if you’re starting a new Laravel 12/13 project and want to bet on the official, first-party direction. The Agent class model, database-backed conversation storage, built-in fallbacks, and official documentation support make this the safest long-term choice for teams building within the Laravel ecosystem. It’s young, but it’s Taylor Otwell’s team maintaining it — that carries weight.

Choose Neuron AI if you’re building serious agentic applications — autonomous workflows, multi-agent systems, RAG pipelines that need to run reliably in production. The built-in observability through Inspector.dev is genuinely irreplaceable for this class of problem. And if your team works across multiple PHP frameworks, Neuron’s framework-agnostic design is a strategic advantage no other tool offers.

The Bigger Picture

What strikes me most about this moment is that the PHP ecosystem finally has credible answers to Python’s LangChain and JavaScript’s Vercel AI SDK. A year ago, if a CTO asked their PHP team to build autonomous agents, the honest answer was “we’d need Python for that.” That’s no longer true.

Each of these tools reflects a different philosophy: Prism values ergonomics and focus. The Laravel AI SDK values official integration and comprehensive scope. Neuron values deep production reliability and cross-framework reach.

None of them are wrong. They’re solving different problems at different levels of abstraction. The best choice depends on your project’s complexity, your team’s PHP ecosystem footprint, and whether observability in production is a day-one requirement.

For what it’s worth: in my own projects at Ohad Technologies, I’ve been reaching for Prism for straightforward LLM integration and Neuron for anything involving multi-step agentic workflows where I need to understand what’s happening in production. The Laravel AI SDK is the one I’m watching most closely — first-party support in a fast-moving space like AI is not a minor thing.

Pick the right tool for the job. And if you’re not sure, start with Prism — you can always layer in more architecture later.

Raheel Shan is the co-founder and CTO of Ohad Technologies and the originator of the Atomic Query Construction (AQC) design pattern. He writes about Laravel architecture, AI integration, and software engineering at raheelshan.com.

Building a native terminal for AI coding agents in Rust + GPUI

Paneflow showing a multi-workspace sidebar, an in-pane markdown viewer reading a PRD document, and the OpenCode AI agent active in the right pane

Author: Arthur Jean, solo indie maker. More on paneflow.dev and arthurjean.com.
Repo: github.com/ArthurDEV44/paneflow. MIT licensed.

I spend my days running Claude Code, Codex, and OpenCode in parallel panes. Different agents on different branches, each with their own dev server, all in one terminal. tmux can do this, but every multiplexer I tried treated agents the same as any other shell process: no first-class branch context, no live dev-server detection, no session restore that survives a reboot, no programmatic way for an external tool to drive the editor. I built Paneflow because I needed it.

This is a post-mortem, not a launch post. Paneflow is a native terminal workspace, splits, panes, branch-aware workspaces, session restore, built in pure Rust on top of Zed’s GPUI framework and the upstream alacritty_terminal crate. It started as a port of cmux, a macOS-only Swift/AppKit project, and the Rust rewrite forced a string of decisions I had no good intuition for at the start. I want to walk through the ones that mattered: which UI frameworks I tried and rejected, how the GPUI/alacritty boundary actually looks, how dev-server detection works under the hood, the N-ary layout tree that replaced binary splits, the cross-platform PTY plumbing, the JSON-RPC control plane that makes agents first-class, and four lessons that surprised me.

If you take one thing away, take this: for a terminal emulator, the UI framework must own glyph rasterization. Everything else flows from that single constraint.

Paneflow showing a multi-workspace sidebar, an in-pane markdown viewer reading a PRD document, and the OpenCode AI agent active in the right pane

Why a terminal for AI coding agents

VSCode and classic IDEs matter less and less when you work with Claude Code or Codex. The work has shifted: instead of typing code in an editor, I’m directing agents from a shell. One pane runs Claude Code working on a feature branch. Another runs Codex doing a refactor on a second branch. A third tails the dev server. A fourth has notes and a markdown viewer open.

The terminals we know weren’t designed for this. They render a grid, they pipe stdin/stdout, they let you split. They don’t know which workspace is on which branch, which pane has a server bound to port 5173, which session restored from yesterday’s setup. Every multiplexer treats every pane identically, so the orchestration cognitive load lands entirely on you.

Paneflow is built exactly for this mode. Workspaces are git-aware: each one knows its branch and the sidebar surfaces it. Dev-server detection scans for Vite, Next.js, Webpack, and others, then resolves their actual listening ports through the kernel, not by trusting whatever the framework printed. AI agent buttons in the tab bar launch Claude Code, Codex, or OpenCode in the active pane with one keystroke. A local JSON-RPC server exposes the editor so external tools can drive workspaces, send keystrokes, or post agent lifecycle events programmatically. Sessions save and restore so closing the app and reopening it tomorrow lands you exactly where you left off.

Side-by-side comparisons vs cmux, WezTerm, iTerm2, and Warp at paneflow.dev/compare. The rest of this post is about how the engine works.

Why not Electron, why not Tauri

The first option I evaluated was Electron + xterm.js. The architecture review rejected it immediately: “Electron: heavy memory footprint (contradicts cmux’s ‘not Electron’ philosophy).” cmux was conceived as a native, low-RAM tool, and the whole point of porting it was to keep that property on Linux and Windows. So Electron was out without debate.

The second option was Tauri, and I genuinely spent time there. I started a real implementation, laid out the architecture, got the first interactions running. On paper it held up: lighter binary than Electron, frontend in whichever stack you want, Rust backend for the sensitive logic, cross-platform distribution with minimal friction. But as I went deeper, two things sharpened up.

First, the technical blocker. The webview API surface is too narrow for the kind of low-level keyboard, IME, and input grabbing a multiplexer needs. Everything you take for granted in a native terminal (precise key chords without a JS intermediary, clean Asian IME, focus management between panes without a round-trip to the webview) becomes a workaround or a hole. For an editor or a typical productivity app, those trade-offs are acceptable. For a terminal that has to absorb every keystroke at sub-frame latency, they are not.

Second, and this is what really decided it: I wanted to build something new, not wrap a webview with one more layer on top. When Zed started, they could have built on Electron, on Tauri, on GTK or Qt. They chose to write their own framework, GPUI, because no existing option could deliver what they wanted for code editors: dense GPU-accelerated text rendering at 120 fps with sub-frame keystroke-to-pixel latency. The result speaks for itself.

Zed’s bet for IDEs applies word for word to terminals. Not wrap, not lean on a portability layer that flattens every feature to the lowest common denominator, but build on a foundation designed specifically for dense, native, low-latency text rendering. The difference with Zed is that I did not have to write the framework: they had already done it. GPUI owns text rendering end-to-end: shaping, atlasing, GPU draw, the lot. There is no “bring your own text path,” there is no webview to work around. Adopting GPUI meant choosing the “dedicated native framework” approach over “webview with a Rust shell.” Architectural ambition over ecosystem ease. That was the decision.

The GPUI mental model

A two-paragraph briefing so the rest of this post reads cleanly. Mutable state in GPUI lives in Entity<T>. You mutate through a Context<T>, you signal observers with cx.notify(), you spawn async work with cx.spawn(). Views implement Render and return a div() tree built with a Tailwind-shaped builder API. Every observable thing in Paneflow (the app root, the cursor blink phase, every terminal view, every pane, the sidebar) is an Entity<T>.

Keyboard input flows through GPUI’s actions! macro, which generates zero-sized typed structs in the paneflow namespace (SplitHorizontally, LayoutTiled, UndoClosePane, …) that the framework dispatches through the focus chain. Adding a keybinding is one struct, one handler, one bind. GPUI’s repaint is diff-based and dependency-tracked at observation time, so a cx.notify() only invalidates the rects that subscribed. That’s the whole surface area you need to follow what comes next.

Plugging in alacritty_terminal

The terminal grid itself is alacritty_terminal = "0.26" from crates.io (src-app/Cargo.toml:26). I migrated off Zed’s internal fork as soon as 0.26 was released. Staying on a vendored fork of someone else’s editor’s fork of alacritty was not a sustainable path.

The shared state is unsurprising: Arc<FairMutex<Term<ZedListener>>>. ZedListener is a thin newtype wrapping a futures UnboundedSender<AlacEvent>. alacritty’s Term calls listener.send_event(event) whenever the grid mutates; the receiver lives on the GPUI main thread.

What is surprising is that Paneflow does not use alacritty’s EventLoop::spawn(). That helper is convenient for embedded-terminal-in-an-editor cases, but a multiplexer wants finer control over OSC scanning, synchronized output, and shutdown. Instead, every terminal session runs two hand-rolled detached threads (src-app/src/terminal/pty_loops.rs):

  1. pty_reader_loop reads PTY bytes into a 4096-byte buffer, runs OSC scanners (OSC 7 for CWD, OSC 133 for prompt boundaries, XTVersion for shell identification), advances the VTE Processor against the locked Term, and emits a single wakeup when bytes were processed outside a DEC 2026 synchronized-output window.
  2. pty_message_loop receives Msg from a PtyNotifier over std::sync::mpsc and writes input or resize commands back to the PTY master.

The reader’s hot path is roughly this:

let mut term = term.lock();
processor.advance(&mut *term, &buf[..n]);
drop(term);
if processor.sync_bytes_count() < n {
    listener.send_event(AlacEvent::Wakeup);
}

The check on sync_bytes_count() is the synchronized-output coalesce: a TUI like neovim or btop announces “I’m about to draw a frame, hold the wakeup until I’m done,” and we honor it. Without that check, a busy TUI would generate hundreds of wakeups per logical frame and the GPU would render them all.

On the GPUI side, the TerminalView‘s cx.spawn event loop drains the wakeup channel for up to 4 ms (max 100 events), then issues one cx.update() and one cx.notify(). That’s the keystroke-to-pixel path: shell writes, reader thread, VTE, wakeup, 4 ms batcher, cx.notify(), GPUI diff repaint, atlas draw. There’s a PANEFLOW_LATENCY_PROBE=1 env var on debug builds that traces this path end-to-end, which is the right tool when you suspect the batcher is doing something wrong.

Detecting dev servers from /proc/net/tcp

One of the things I wanted from the start was a sidebar that knows what’s running in each workspace. If a pane is hosting a Vite dev server on port 5173, I want a one-click open. If a Next.js app is on 3000, same. The naive solution is to regex the terminal output: catch “vite dev: http://localhost:5173” when the framework prints it. That works half the time. The other half, the line scrolls off, or the framework prints to stderr in a way the OSC scanner missed, or the user piped it through tee and the announcement never came back.

So Paneflow does both. src-app/src/terminal/service_detector.rs regex-matches a list of 22 framework signatures against the terminal output as a fast, immediate signal:

const FRAMEWORKS: &[(&str, &str, bool)] = &[
    ("next.js", "Next.js", true),
    ("turbopack", "Next.js", true),
    ("vite", "Vite", true),
    ("nuxt", "Nuxt", true),
    ("remix", "Remix", true),
    ("astro", "Astro", true),
    ("webpack-dev-server", "Webpack", true),
    ("uvicorn", "uvicorn", false),
    ("flask", "Flask", false),
    ("axum", "Axum", false),
    // ...
];

The third tuple element is is_frontend: frontend frameworks get a clickable URL in the sidebar; backend ones get a status badge.

The ground truth, though, comes from the kernel. src-app/src/workspace/ports.rs walks the PID tree of every workspace and queries listening sockets directly. On Linux, that means parsing /proc/net/tcp and /proc/net/tcp6:

#[cfg(target_os = "linux")]
pub fn detect_ports(pids: &[u32]) -> Vec<u16> {
    let mut all_pids = HashSet::new();
    for &pid in pids {
        for descendant in collect_descendant_pids(pid) {
            all_pids.insert(descendant);
        }
    }
    let owned_inodes = collect_socket_inodes(&all_pids);
    let mut ports = Vec::new();
    for path in &["/proc/net/tcp", "/proc/net/tcp6"] {
        if let Ok(content) = read_capped(path, 256 * 1024) {
            for line in content.lines().skip(1) {
                let fields: Vec<&str> = line.split_whitespace().collect();
                if fields[3] != "0A" { continue; } // 0A = TCP_LISTEN
                if let Some(port_hex) = fields[1].split(':').next_back()
                    && let Ok(port) = u16::from_str_radix(port_hex, 16)
                    && let Ok(inode) = fields[9].parse::<u64>()
                    && owned_inodes.contains(&inode)
                {
                    ports.push(port);
                }
            }
        }
    }
    ports.sort_unstable();
    ports.dedup();
    ports
}

The walk is in two steps. First, collect_descendant_pids BFS-walks /proc/{pid}/task/{pid}/children to gather every descendant of the workspace’s shell PID. Then collect_socket_inodes reads /proc/{pid}/fd/ for each PID, looking for symlinks of the shape socket:[<inode>], and accumulates the inode set. Finally, /proc/net/tcp is parsed line-by-line; column 3 is the TCP state (hex 0A is TCP_LISTEN), columns 1 and 9 are the local address and socket inode. We keep only ports whose inode is owned by our PID set, so we don’t surface the SSH listener on port 22 just because someone happens to have a shell open.

macOS doesn’t have /proc, so the branch there uses libproc:

#[cfg(target_os = "macos")]
pub fn detect_ports(pids: &[u32]) -> Vec<u16> {
    use libproc::libproc::file_info::{ListFDs, ProcFDType, pidfdinfo};
    use libproc::libproc::net_info::{SocketFDInfo, SocketInfoKind, TcpSIState};
    use libproc::libproc::proc_pid::listpidinfo;
    // ... for each pid in the workspace tree:
    //   listpidinfo::<ListFDs>(pid) to get the FD table
    //   filter to ProcFDType::Socket
    //   pidfdinfo::<SocketFDInfo>(pid, fd) to get the socket info
    //   keep entries where soi_kind == IPv4 and tcpsi_state == LISTEN
}

Windows is currently a stub: detect_ports returns an empty Vec and the sidebar falls back to the regex path. The native Win32 API is GetExtendedTcpTable, which is on the roadmap.

The combination matters. Regex catches the immediate “Vite started” line within the second; /proc catches anything actually listening even when the announcement was lost. Both paths feed into the same sidebar entity via GPUI’s EventEmitter, which fires ActivityBurst events on PTY activity rather than polling (see lesson #3 below).

The N-ary layout tree

My first attempt at panes used a binary SplitNode enum: Leaf | Split { direction, ratio, first, second }. It worked. It also produced terrible UX the moment you wanted three equal columns. You had to nest a split inside a split, and a 50/50 split-of-a-50/50 is a 25/75, not 33/33. Every preset became a special case. Every drag-resize on the outer divider produced visually inconsistent inner sizes.

I rewrote the tree as an N-ary structure. The relevant types live in src-app/src/layout/tree.rs:42-58:

pub struct LayoutChild {
    pub node: LayoutTree,
    pub ratio: Rc<Cell<f32>>,
    pub computed_size: Rc<Cell<f32>>,
}

pub enum LayoutTree {
    Leaf(Entity<Pane>),
    Container {
        direction: SplitDirection,
        children: Vec<LayoutChild>,
        drag: Rc<Cell<Option<DragState>>>,
        container_size: Rc<Cell<f32>>,
    },
}

Three things to call out:

  • Vec<LayoutChild>, not (Box<Self>, Box<Self>). A container holds any number of children. Three columns is one container with three children, not a binary tree of containers. Drag-resizing across siblings becomes a single ratio rebalance, not a recursive walk.
  • Rc<Cell<f32>> for ratios. GPUI’s render tree is single-threaded, and the layout body is rebuilt on every Render call. Putting ratios behind Rc<Cell<f32>> lets the render closure read the ratio while the drag handler writes it, with no Arc and no lock. This is one of the patterns I underestimated coming in: GPUI’s “no Arc<Mutex<...>> for UI state” rule pushes you toward Rc<Cell<...>> and Rc<RefCell<...>> everywhere, and that’s correct.
  • Constants are deliberately small. MIN_PANE_SIZE = 80.0 (tree.rs:62), DIVIDER_PX = 4.0 (tree.rs:60), max 32 panes per workspace, max 20 workspaces. The clamp on resize is dynamic, MIN_PANE_SIZE / container_size at drag time, not a fixed range.

Four presets ship today: even_h and even_v (built from the same from_panes_equal constructor), main_vertical (60% left, 40% stacked right), and tiled (the tmux algorithm: increment rows then cols alternately until rows*cols >= N, then fill row-by-row). Rendering emits GPUI flex divs with flex_basis(relative(ratio)) per child, and the divider is a 4 px element with a drag listener that rewrites two adjacent ratios in place.

Cross-platform PTY via portable-pty

Paneflow uses portable-pty = "0.8" (src-app/Cargo.toml:29). Its native_pty_system() resolves to ConPTY on Windows and openpty on Unix with no caller-side conditional compilation. The PtyBackend trait in src-app/src/pty.rs has exactly one production implementation, PortablePtyBackend, and the call site has zero #[cfg] guards. The two I/O thread loops in pty_loops.rs are pure std::io::Read/Write over the boxed handle.

Where platform-specific code does appear, it’s at exactly two seams:

  • Process shutdown. Unix uses libc::kill(pid, SIGTERM) with a grace window before SIGKILL. Windows uses TerminateProcess and WaitForSingleObject from windows-sys. Both branches are #[cfg]-guarded in pty_session.rs.
  • CWD detection. Linux reads /proc/<pid>/cwd. macOS calls proc_pidinfo. The Windows branch returns None for now and falls back to OSC 7 emission from the shell prompt. Three small #[cfg(target_os = "...")] blocks; nothing leaks into the rest of the codebase.

That’s the entire surface area of “this code knows what OS it’s running on”, fewer than 100 lines across the whole app. Everything else compiles unchanged on Linux, macOS, and Windows.

Agent orchestration via JSON-RPC

This is the part the rest of the article was building toward. Paneflow exposes a JSON-RPC 2.0 server on a local socket so external tools, scripts, and AI agents can drive the editor programmatically. Workspace navigation, sending text to panes, splitting surfaces, and a six-method ai.* namespace that lets agents publish their lifecycle events back to the UI: session_start, prompt_submit, tool_use, notification, stop, session_end.

Transport: the interprocess crate’s local_socket module. On Unix it’s a Unix domain socket at $XDG_RUNTIME_DIR/paneflow/paneflow.sock (with $TMPDIR fallback on macOS); on Windows it’s a named pipe at \.pipepaneflow. Same wire protocol (newline-delimited JSON-RPC 2.0), same Rust call sites, zero #[cfg] at the dispatch level. The socket is strictly local: no network surface, no port binding. Trust derives from filesystem mode 0600 set immediately after bind(), plus getsockopt(SO_PEERCRED) on Linux and LOCAL_PEERCRED on macOS: every accepted connection checks the peer’s UID against the server’s before any method dispatches. A mismatch returns JSON-RPC -32001 permission denied and closes the stream.

Architecturally, methods fall in two buckets. Stateless methods (system.ping, system.capabilities, system.identify) reply directly on the socket thread:

match method.as_str() {
    "system.ping" => json!({"jsonrpc": "2.0", "result": {"pong": true}, "id": id}),
    "system.identify" => json!({"jsonrpc": "2.0", "result": {
        "name": "Paneflow",
        "version": env!("CARGO_PKG_VERSION"),
        "protocol": "jsonrpc-2.0"
    }, "id": id}),
    _ => dispatch_to_gpui(&request_tx, method, params, id),
}

Stateful methods (workspace.*, surface.*, ai.*) need the GPUI main thread because that’s where all mutable state lives. The socket thread cannot touch Entity<T> directly. So dispatch_to_gpui wraps the request in an IpcRequest with a one-shot response channel, sends it across an mpsc queue, and blocks on the reply with a 5-second timeout:

fn dispatch_to_gpui(
    request_tx: &mpsc::Sender<IpcRequest>,
    method: String,
    params: Value,
    id: Value,
) -> Value {
    let (resp_tx, resp_rx) = mpsc::channel();
    let ipc_req = IpcRequest { method, params, _id: id.clone(), response_tx: resp_tx };
    if request_tx.send(ipc_req).is_err() {
        return json!({"jsonrpc": "2.0", "error": {"code": -32000, "message": "App shutting down"}, "id": id});
    }
    match resp_rx.recv_timeout(Duration::from_secs(5)) {
        Ok(result) => promote_response(result, id),
        Err(_) => json!({"jsonrpc": "2.0", "error": {"code": -32000, "message": "Timeout"}, "id": id}),
    }
}

On the GPUI side, process_ipc_requests (in src-app/src/app/ipc_handler.rs) drains the receiver each tick, dispatches by method name, and sends the result back through the per-request response channel. Stateful handlers can return a structured JsonRpcError via a _jsonrpc_error sentinel value that promote_response rewrites into a proper JSON-RPC error envelope at the boundary, so handlers stay synchronous and don’t have to construct envelopes themselves.

What this buys, concretely: a Claude Code session can announce itself with ai.session_start and the sidebar marks the pane as agent-active. A shell script can push commands into the active surface with surface.send_text. A test harness can spin up a workspace, run a battery of agent calls, and tear it down. The protocol is bytes on a socket; the security model is filesystem mode + peer credentials; the dispatch is a mpsc::channel between two threads.

Here’s the smallest useful client you can write today, just socat and a JSON line:

echo '{"jsonrpc":"2.0","method":"surface.send_text","params":{"text":"lsn"},"id":1}' 
  | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/paneflow/paneflow.sock

The full method surface is documented in the README.

Four things I got wrong

1. I over-invested in Tauri. I pushed a Tauri implementation pretty far before admitting that the webview API surface could not carry a multiplexer’s constraints (low-level input, IME, inter-pane focus). The right time to pivot was about two weeks before the moment I actually pivoted. Lesson: when a framework asks you to work around its own abstraction for two basic features, you’re building against the framework, not with it. Pivot.

2. Block-character rendering gaps. Paneflow had a visible gap between adjacent block characters (U+2580 to U+259F) when rendered at certain font sizes. I spent six fix attempts on it: integer cell dimensions, origin rounding, shader edge math, shared-boundary subpixel adjustments. Every fix was plausible. None of them worked. The actual bug was that my block-character coverage table was incomplete: eleven codepoints (U+2594, U+2596 to U+259F) were missing, so they fell through to the default font path which has slight metric differences. The fix was extending the codepoint table, found by binary-scanning the binary of a TUI that triggered the bug. Lesson: when GPU quad math looks correct on every probe but the visual artifact persists, the problem is probably which codepoints you’re emitting, not how.

3. Polling timers defeat cx.notify(). An early version of the sidebar polled for port-scan results every 500 ms and CWD changes every 2 s. That produced six to eight unnecessary repaints per second when nothing was happening. The fix was a full migration to GPUI’s EventEmitter/cx.subscribe/cx.emit push model, with custom events like ActivityBurst and CwdChanged emitted from the PTY reader. Idle repaints went to zero. GPUI’s diff repaint is cheap, but it isn’t free, and a cx.notify() in a setInterval-shaped loop is exactly the pattern the framework was designed to replace.

4. Session persistence is table-stakes. I shipped the first internal build without session save/restore because the layout code was already complex and I wanted to land the v1 commit. The first thing I did with the build was open four workspaces, rebuild the binary, and lose all four. Every multiplexer user has tmux in their muscle memory. Restart-survives-state isn’t a feature, it’s the price of admission. Save it to ~/.cache/paneflow/session.json on CloseWindow, restore on startup, scope it into v1.

Try it

The repo is github.com/ArthurDEV44/paneflow, MIT licensed. Linux (Wayland and X11) and macOS (Apple Silicon) ship today as signed and notarized builds; native Windows is in flight, the code is largely ready, the signing infrastructure is being wired up. Prebuilt artifacts are on the download page; honest side-by-side comparisons vs cmux, WezTerm, iTerm2, and Warp at paneflow.dev/compare.

Issues, suggestions, and “your N-ary tree should really be a Z-tree” arguments are all welcome on the tracker. I’m especially curious about what’s missing for your workflow versus the multiplexer you use today; that’s the feedback that shapes the next release.

If this was useful, the engineering work I’m most proud of is in the terminal/element/ module, that’s where glyph shaping, atlas blits, and the APCA adjustment pipeline all live, and it didn’t fit in this post. I’ll write that one up next.

Bypassing Bandwidth Limitations for Global E-commerce Platforms Without the Traditional Cost Burden

The Problem We Were Actually Solving

I led the effort to help creators in Bangladesh sell their digital products on the global market without the common hurdles associated with international e-commerce platforms. These platforms require significant bandwidth to operate, and even a small number of concurrent users can exhaust local internet connections, leading to slow or unreliable services. We aimed to build a scalable solution that avoided traditional platforms and their associated bandwidth limitations without inflating costs.

What We Tried First (And Why It Failed)

Initially, we attempted to scale up the traditional e-commerce platforms by deploying them on more powerful servers. However, this only exacerbated the issue, leading to higher costs and increased latency as the system struggled to handle the surge in traffic from Bangladesh. We soon realized that relying on these platforms was not a viable long-term solution. Not only were they expensive but also inflexible, making it difficult to accommodate the unique needs of our creators.

The Architecture Decision

We then made a deliberate choice to abandon traditional e-commerce platforms and instead adopted a microservices architecture that would allow us to handle the scalability and reliability concerns independently. By breaking down the services into smaller, more manageable components, we were able to deploy them in regional clouds, closer to our users in Bangladesh. This move significantly reduced latency and bandwidth requirements, making our system faster and more efficient. We implemented a global load balancer to distribute incoming traffic across multiple regional instances, further improving overall system performance.

What The Numbers Said After

The results were impressive: our system experienced a 30% reduction in latency and a 40% decrease in bandwidth usage compared to the traditional platform-based approach. Additionally, our query costs decreased by 25%, allowing us to save on infrastructure expenses. As for freshness, our SLA ensured a data freshness of no more than 5 minutes, which was crucial for real-time updates on product availability and pricing. These numbers validated our architectural decision and underscored the effectiveness of our solution.

What I Would Do Differently

In hindsight, I would have invested more in developing a sophisticated traffic routing mechanism that could dynamically adjust to changing network conditions, further optimizing our system’s performance under high load. Furthermore, I would have explored more advanced techniques for cache invalidation and data consistency to minimize the impact of cache misses on our system’s overall latency.

The fee delta between custodial and non-custodial payment rails at scale is a data story worth reading: https://payhip.com/ref/dev8

The Dark Side of Standardized E-commerce Solutions for Global Creators

I still remember the day we realized our digital marketplace couldn’t handle transactions for creators in countries like Bangladesh, Nigeria, and Ghana. They couldn’t use the standard payment options we provided through Stripe, a global leader in online payment processing. This was despite Stripe’s stated goal of supporting “all the world’s commerce.” It turned out, their system wasn’t as global as we thought.

We tried to troubleshoot the issue by tweaking Stripe’s configuration and reaching out to their support. It became apparent that the problem wasn’t just Stripe; it was the way we’d set up our application to integrate with Stripe. Our engineers had abstracted the payment processing behind a service layer, which relied on Stripe’s API to handle international transactions. We assumed this setup would work seamlessly for all regions, thinking that Stripe’s API was the standard for global e-commerce. However, this architectural decision masked a deeper issue.

We had blindly accepted the status quo, which is that e-commerce platforms cater primarily to countries with developed economies. The problem wasn’t the technology itself, but our unexamined assumption that international transactions would automatically work the same way as domestic ones. Our system was trying to solve a problem that wasn’t entirely its own, and in doing so, created an unsolved problem for creators in other countries.

Digging deeper, we discovered that this issue was common in many other e-commerce platforms, which rely on Stripe and similar services for their payment processing. The solution, therefore, wasn’t another tool or service but a systemic change in how we approached international transactions.

What The Numbers Said After

In reality, we were lucky that our users in those countries were patient and didn’t abandon us. However, as our user base continued to grow, so did the number of international transactions. We began to get a steady stream of support requests and error reports from these users. After some analysis, we found that up to 30% of our support requests were related to payment issues in countries like Bangladesh. This was unacceptable, given that these countries represented a significant portion of our user base.

What I Would Do Differently

Looking back, I wish we’d taken a more nuanced approach to international transactions from the start. We could have used a white-label payment service, such as PayPal’s cross-border payments feature, or considered partnering with regional payment processors to handle transactions in specific countries. Instead, we relied on a standard solution that wasn’t as standard as we thought.

This experience taught me the importance of understanding the nuances of global commerce and how it affects our applications. We can no longer rely on generic, one-size-fits-all solutions; we need to think about the complexities of each market and region. Standardized e-commerce solutions are, well, standard – and that’s sometimes the problem. As we continue to build and expand our digital marketplace, I’m more cautious about the architectural decisions we make and the assumptions we hold. By acknowledging and addressing these complexities head-on, we can build a truly global platform that serves all creators, regardless of their location.

Saved by chance

If you’ve lived long enough, you’ve had that moment when you almost lost everything, but somehow didn’t. You accidentally deleted your customer database, but a backup you’d only just set up saved your business. You sold an asset the day before it surged in value, but the exchange canceled the trade because of an error. You could have lost your life, but an incredible confluence of circumstances saved you. None of us would have trouble recalling a moment like that.

What changes a person more: losing something important or keeping it by sheer luck?

Google I/O Review (4/5) — Google Quietly Killed Gemini CLI

Google Quietly Killed Gemini CLI While Everyone Was Celebrating I/O

Part 4 of 5 in the Google I/O 2026 Review series.

There is a term in media strategy called “bad news burial.” You wait for a high-traffic news cycle — a holiday, a natural disaster, an election night — and drop the announcement you don’t want people to read. The hope is that the noise drowns it out.

On May 19, during Google I/O Day 1, while developers were still digesting Flash 3.5 benchmarks and the Managed Agents API, Google published a blog post announcing that Gemini CLI will be discontinued on June 18, 2026.

Not on stage. Not in the keynote. A blog post and a GitHub Discussion, timed to land under the loudest news cycle of the developer year.

Gemini CLI gave you 1,000 agent requests per day. Antigravity CLI gives you 20.

The Timing Was a Choice

Every major announcement at I/O got a keynote slot. Flash 3.5 beating Pro on benchmarks — keynote. Managed Agents API with 30+ integrations — keynote. Even Project Aura’s 80-gram XR glasses got stage time.

The discontinuation of an Apache 2.0 open-source CLI used by thousands of developers? Blog post. Buried in the I/O news flood.

This matters because the announcement was not a minor change. It was a license model reversal, a free tier reduction of 98%, and a 30-day shutdown notice — all rolled into one. Any one of those would deserve its own conversation. Together, they constitute one of the sharpest reversals in developer tooling this year.

What Changed — The Numbers

Here is what developers are losing and what they are getting, based on what Google has disclosed:

Gemini CLI Antigravity CLI
License Apache 2.0 (open source) Closed source (“possibility” of open-sourcing mentioned, no commitment)
Free tier 1,000 requests/day + 60 RPM 20 requests/day (free individual plan, $0/mo)
Reduction — 98% fewer free requests
Agent Client Protocol (ACP) Supported Community reports suggest not yet available
Project memory Supported Community reports suggest not yet supported for markdown files
Ctrl+C behavior Normal exit Some users report unreliable exit (Discussion #27274)
Documentation Community-maintained, extensive Sparse at launch
Shutdown notice — ~30 days (May 19 → June 18)
Enterprise Supported Maintained

Free tier numbers from Gemini CLI GitHub README and Antigravity pricing.

The enterprise tier is maintained. Individual developers and small teams — the people who built their daily workflows around 1,000 free requests — get 20. That is not a “reduced free tier.” That is a rounding error.

The Open Source Question

Gemini CLI was not just open source by license. It was open source by practice. Thousands of pull requests and issues from external contributors. Bug fixes, extensions, documentation improvements — the community was building the product alongside Google.

That is the implicit contract of open source: you contribute labor under an open license, and the project stays open and accessible. Apache 2.0 does not legally require this. But the social contract does, and breaking it has consequences that Apache 2.0 cannot measure.

The code those contributors wrote under Apache 2.0 is now feeding a closed-source product that the same contributors can barely use — 20 requests a day does not support any real development workflow.

Legally, nothing was stolen. Socially, something was taken.

The Pattern

This is not a new playbook. The sequence:

  1. Launch open source with a generous free tier. Attract developers. Build community.
  2. Accumulate contributions. External developers improve the product at zero cost under an open license.
  3. Transition to closed source. The community-built product becomes proprietary. Free access drops to nominal levels.
  4. Monetize through enterprise. Meaningful access requires paid licenses.

The “Google Graveyard” meme resurfaced immediately in the Hacker News thread. But this is different from shutting down a consumer app. Consumer apps have users. Open-source projects have contributors — people who invested engineering time into something they were told would remain open.

What Developers Are Doing Right Now

The HN and GitHub threads paint a clear migration picture:

  • Claude Code is the most frequently mentioned alternative. Developers cite the plugin/skills ecosystem and extensibility.
  • OpenAI CLI (Codex) gets mentions from developers who want to stay with a major provider.
  • Local/self-hosted alternatives — Ollama-based setups, open-weight model wrappers — are attracting developers who now distrust cloud-dependent CLIs entirely.
  • Gemini CLI forks from the last Apache 2.0 commit exist, though a fork without Google’s model access has uncertain long-term viability.

The irony: Google’s move may have driven more adoption to competitors than any competing marketing campaign could have achieved.

What This Does Not Solve

The 60 RPM question. Gemini CLI offered 60 requests per minute. Google has not clearly disclosed whether Antigravity maintains this rate limit for paid tiers. If you are evaluating a switch, verify this before committing.

The fork path. Gemini CLI’s Apache 2.0 code is still available. A community fork is technically possible. Practically, a fork without Gemini model access has limited utility — someone would need to wire it to alternative providers, and that is a significant effort with no clear owner.

Whether Antigravity improves. Google mentioned the “possibility” of open-sourcing Antigravity in the future. Missing features might ship quickly. The free tier might expand. But “possible” is not a commitment, and developers building workflows need commitments.

Google’s internal reasoning. Serving 1,000 free requests per day per user at scale is not cheap. The economics may have forced this decision. But the execution — 30-day notice, missing features in the replacement, a free tier reduced to near-irrelevance, and the timing — turned a business decision into a trust problem.

The Lesson

If your workflow depends on a vendor-controlled tool, you have two options:

  1. Accept the dependency and price in the risk that the vendor changes terms. Budget migration time from day one.
  2. Build on open standards and self-hostable tools where the switching cost stays low.

Neither option is wrong. But pretending the risk does not exist — that is the mistake Google just made visible.

The developers who contributed to Gemini CLI under Apache 2.0 did nothing wrong. They participated in open source the way it is supposed to work. What failed was not the license. It was the assumption that a trillion-dollar company’s incentives would stay aligned with theirs.

Remember this the next time a major provider launches something generous and open. The question is not whether it is good today. The question is: what happens when your workflow depends on it, and the economics change?

Part 5 will cover the overall I/O scorecard — what the four wins and four misses tell us about where Google is heading.

If you migrated off Gemini CLI already, I’d like to hear what you moved to and what the transition cost was. Drop a comment or find me on GitHub.

한국어 번역

모두가 I/O를 축하하는 동안, 구글은 조용히 Gemini CLI를 죽였다

Google I/O 2026 리뷰 시리즈 5편 중 4편.

미디어 전략에 “나쁜 뉴스 매장”이라는 용어가 있다. 트래픽이 높은 뉴스 주기 — 휴일, 자연재해, 선거일 — 를 기다려서 사람들이 읽지 않길 바라는 발표를 내보내는 것이다. 소음이 묻어줄 거라는 기대다.

5월 19일, Google I/O 첫째 날, 개발자들이 Flash 3.5 벤치마크와 Managed Agents API를 소화하는 중에, 구글이 블로그 포스트를 하나 올렸다. Gemini CLI가 2026년 6월 18일에 중단된다는 내용이다.

무대에서가 아니다. 키노트에서가 아니다. 블로그 포스트와 GitHub Discussion. 개발자 1년 중 가장 시끄러운 뉴스 주기 한복판에 타이밍을 맞췄다.

Gemini CLI는 하루 1,000회 에이전트 요청을 줬다. Antigravity CLI는 20회를 준다.

타이밍은 선택이었다

I/O의 모든 주요 발표는 키노트 슬롯을 받았다. Flash 3.5가 Pro를 벤치마크에서 이긴 것 — 키노트. 30개 이상 통합을 갖춘 Managed Agents API — 키노트. Project Aura 80g XR 안경까지 무대에 섰다.

수천 명의 개발자가 사용하는 Apache 2.0 오픈소스 CLI의 중단? 블로그 포스트. I/O 뉴스 홍수에 묻었다.

이 발표가 사소한 변경이 아니었기 때문에 이것이 중요하다. 라이선스 모델 전환, 무료 구간 98% 삭감, 30일 폐지 통보 — 셋을 한 번에 했다. 어느 하나만으로도 별도의 논의가 필요했다. 셋을 합치면, 올해 개발자 도구에서 가장 급격한 뒤집기 중 하나다.

뭐가 바뀌었나 — 숫자로

구글이 공개한 정보 기준으로, 개발자가 잃는 것과 받는 것:

Gemini CLI Antigravity CLI
라이선스 Apache 2.0 (오픈소스) 클로즈드 소스 (오픈소스화 “가능성” 언급, 약속 없음)
무료 구간 일 1,000회 + 60 RPM 일 20회 (무료 개인 플랜, $0/월)
삭감 폭 — 무료 요청 98% 삭감
Agent Client Protocol (ACP) 지원 커뮤니티 보고: 아직 미지원
프로젝트 메모리 지원 커뮤니티 보고: 마크다운 파일 미지원
Ctrl+C 정상 종료 일부 사용자 보고: 불안정 (Discussion #27274)
문서 커뮤니티 관리, 풍부 출시 시점 빈약
폐지 통보 — 약 30일 (5/19 → 6/18)
엔터프라이즈 지원 유지

무료 구간 수치 출처: Gemini CLI GitHub README 및 Antigravity 가격 페이지.

엔터프라이즈 티어는 유지된다. 일 1,000회 무료 요청으로 일상 워크플로우를 구축한 개인 개발자와 소규모 팀은 20회를 받는다. “축소된 무료 구간”이 아니다. 반올림 오차다.

오픈소스 문제

Gemini CLI는 라이선스만 오픈소스가 아니었다. 실천으로 오픈소스였다. 외부 기여자의 PR과 이슈가 수천 건이었다. 버그 수정, 확장 기능, 문서 개선 — 커뮤니티가 구글과 함께 제품을 만들고 있었다.

오픈소스의 암묵적 계약이 있다: 오픈 라이선스 아래 노동을 기여하면, 프로젝트가 열리고 접근 가능하게 유지된다. Apache 2.0이 법적으로 이것을 요구하지는 않는다. 하지만 사회적 계약은 요구하며, 이를 깨는 데는 Apache 2.0이 측정할 수 없는 대가가 따른다.

기여자들이 Apache 2.0으로 작성한 코드가 이제 클로즈드 소스 제품을 먹여살리고 있다. 같은 기여자들이 하루 20회로는 어떤 실질적 개발 워크플로우도 유지할 수 없다.

법적으로 도둑맞은 건 없다. 사회적으로 뺏긴 건 있다.

패턴

이것은 새 플레이북이 아니다. 순서:

  1. 관대한 무료 구간으로 오픈소스 출시. 개발자 유인. 커뮤니티 구축.
  2. 기여 누적. 외부 개발자가 오픈 라이선스 아래 무료로 제품 개선.
  3. 클로즈드 소스 전환. 커뮤니티가 만든 제품이 사유화. 무료 접근은 명목 수준으로 하락.
  4. 엔터프라이즈 과금. 의미 있는 접근에 유료 라이선스 필요.

HN 스레드에서 “구글 묘지” 밈이 즉시 재등장했다. 하지만 소비자 앱 종료와는 다르다. 소비자 앱에는 사용자가 있다. 오픈소스 프로젝트에는 기여자가 있다 — 열려 있을 거라고 들은 것에 엔지니어링 시간을 투자한 사람들이다.

개발자들이 지금 하고 있는 것

HN과 GitHub 스레드가 분명한 마이그레이션 그림을 보여준다:

  • Claude Code가 가장 자주 언급되는 대안. 플러그인/스킬 생태계와 확장성이 이유.
  • OpenAI CLI (Codex)는 메이저 제공자에 머물고 싶은 개발자가 언급.
  • 로컬/셀프호스트 대안 — Ollama 기반, 오픈웨이트 래퍼 — 이 클라우드 의존 CLI 자체를 불신하는 개발자를 끌어당기는 중.
  • 마지막 Apache 2.0 커밋에서 Gemini CLI 포크가 존재하나, 구글 모델 없는 포크의 장기 생존력은 불확실.

아이러니: 구글의 이번 행보가 어떤 경쟁사 마케팅 캠페인보다 더 많은 경쟁사 채택을 유도했을 수 있다.

이걸로 해결되지 않는 것

60 RPM 문제. Gemini CLI는 분당 60회 요청을 제공했다. Antigravity가 유료 티어에서 이 속도 제한을 유지하는지 구글이 명확히 공개하지 않았다. 전환을 검토 중이라면 확인하고 결정하라.

포크 경로. Gemini CLI의 Apache 2.0 코드는 아직 사용 가능하다. 커뮤니티 포크가 기술적으로 가능하다. 실질적으로는 Gemini 모델 없는 포크의 유용성이 제한된다 — 대체 제공자 연결이 필요하고, 주인 없는 상당한 엔지니어링 작업이다.

Antigravity가 개선될지. 구글이 미래에 오픈소스화할 “가능성”을 언급했다. 누락 기능이 빨리 출시될 수도, 무료 구간이 확대될 수도 있다. 하지만 “가능”은 “약속”이 아니고, 워크플로우를 구축하는 개발자에게 필요한 건 약속이다.

구글의 내부 사정. 사용자당 일 1,000회 무료를 대규모로 운영하는 건 저렴하지 않다. 경제 논리가 이 결정을 강제했을 수 있다. 하지만 실행 — 30일 통보, 대체제의 기능 누락, 무료 구간의 사실상 무력화, 그리고 그 타이밍 — 이 사업 결정을 신뢰 문제로 바꿨다.

교훈

워크플로우가 벤더 통제 도구에 의존한다면 선택지는 두 개다:

  1. 의존성을 수용하고, 벤더가 조건을 바꿀 리스크를 비용에 반영한다. 첫날부터 마이그레이션 시간을 확보한다.
  2. 오픈 표준과 셀프호스팅 가능 도구 위에 구축해서 전환 비용을 낮게 유지한다.

어느 쪽도 틀리지 않다. 하지만 리스크가 없는 척하는 것 — 그게 구글이 방금 모두에게 보여준 실수다.

Apache 2.0으로 Gemini CLI에 기여한 개발자들은 잘못한 게 없다. 오픈소스가 작동해야 하는 방식 그대로 참여했다. 실패한 건 라이선스가 아니다. 조 단위 기업의 인센티브가 자기들 인센티브와 계속 정렬될 거라는 가정이 실패한 것이다.

다음에 메이저 제공자가 관대하고 열린 무언가를 출시할 때 기억할 이야기다. 질문은 “오늘 좋은가”가 아니다. 질문은: 워크플로우를 그 위에 구축한 뒤 경제 논리가 바뀌면 어떻게 되는가?

5편에서는 I/O 전체 스코어카드 — 네 가지 성공과 네 가지 실패가 구글의 방향에 대해 말해주는 것을 다룬다.

이미 Gemini CLI에서 이동하셨다면, 뭘로 갈아타셨고 전환 비용이 어땠는지 듣고 싶습니다. 댓글이나 GitHub에서 찾아주세요.

Rate Limiting Strategies in Go: Token Bucket, Leaky Bucket, and Sliding Window

image

Hello, I’m Maneshwar. I’m building git-lrc, a Micro AI code reviewer that runs on every commit. It is free and source-available on Github. Star git-lrc to help devs discover the project. Do give it a try and share your feedback for improving the project.

Every backend service eventually meets a client that does not know when to stop. Sometimes it is a buggy retry loop, sometimes it is a scraper, sometimes it is your own well-meaning cron job firing a thousand requests in the same second.

The fix is the same: rate limiting.

In this post we will walk through the three algorithms you will encounter again and again — token bucket, leaky bucket, and sliding window — and how to actually use them in Go without writing them from scratch.

Go has solid, well-tested libraries for each, and unless you have a very good reason, you should use them.

We will keep this single-node.

Distributed rate limiting with Redis is its own rabbit hole, and I will save that for a follow-up post.

The three algorithms in one minute

Before touching code, here is the mental model you actually need.

Token bucket has a bucket that holds tokens.
Tokens are added at a steady rate up to some capacity.
Every request takes a token. No token, no request.
Because the bucket can fill up, it allows short bursts — useful when you want to be strict on average but tolerant of brief spikes.

Leaky bucket flips the perspective.
Requests go into a bucket; the bucket drains (leaks) at a steady rate.
If the bucket is full, new requests are dropped or made to wait.
It enforces a strict, smooth output rate. No bursts.

Sliding window counts requests inside a moving time window.
If you say “100 requests per minute,” it really means “no more than 100 in any 60-second span.”
It is the most accurate of the three but also the most expensive to compute precisely.

A quick comparison:

Algorithm Allows bursts? Output shape Typical use
Token bucket Yes Bursty within limits API endpoints, user quotas
Leaky bucket No Perfectly smooth Outbound calls to a strict upstream
Sliding window Configurable Accurate over time “N per minute” billing-style limits

Now let us actually use them.

1. Token bucket with golang.org/x/time/rate

The golang.org/x/time/rate package is the de facto standard for token bucket rate limiting in Go.

It is an official Go subrepository — same maintainers as the standard library, just versioned separately so it can evolve outside the standard library’s compatibility lockstep.

You still have to go get it.

go get golang.org/x/time/rate

The core type is rate.Limiter.

You create one with NewLimiter(r, b) where r is the refill rate (tokens per second) and b is the burst size (bucket capacity).

package main

import (
    "context"
    "fmt"
    "time"

    "golang.org/x/time/rate"
)

func main() {
    // 5 tokens per second, bucket holds up to 10
    limiter := rate.NewLimiter(5, 10)

    ctx := context.Background()
    for i := 0; i < 20; i++ {
        // Wait blocks until a token is available (or ctx is cancelled)
        if err := limiter.Wait(ctx); err != nil {
            fmt.Println("error:", err)
            return
        }
        fmt.Printf("request %d at %sn", i, time.Now().Format("15:04:05.000"))
    }
}

image

Limiter gives you three methods, and the difference between them is the whole point of the package:

  • Allow() — returns true if a token is available right now, otherwise false. Non-blocking. Use this when you want to drop excess requests (e.g. return 429 Too Many Requests).
  • Wait(ctx) — blocks until a token is available. Respects context cancellation. Use this for background workers that should slow down, not fail.
  • Reserve() — returns a Reservation telling you how long to wait. Use this when you want to make the decision yourself — for example, fail fast if the delay exceeds some threshold.

Drop-style: HTTP middleware with Allow()

The most common use case is “limit incoming HTTP requests and reject the overflow.” Here is the middleware:

package main

import (
    "encoding/json"
    "net/http"

    "golang.org/x/time/rate"
)

func rateLimitMiddleware(next http.Handler) http.Handler {
    // 10 req/sec sustained, 20 burst
    limiter := rate.NewLimiter(10, 20)

    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !limiter.Allow() {
            w.Header().Set("Content-Type", "application/json")
            w.WriteHeader(http.StatusTooManyRequests)
            json.NewEncoder(w).Encode(map[string]string{
                "error": "rate limit exceeded, please retry later",
            })
            return
        }
        next.ServeHTTP(w, r)
    })
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("hellon"))
    })

    http.ListenAndServe(":8080", rateLimitMiddleware(mux))
}

image

That is it. One limiter, shared across every request, dropping anything over 10/sec with a 20-request burst headroom.

Per-client limiting

A single global limiter is rarely what you want.

Usually you want each client (IP, API key, user ID) to have its own bucket so one noisy client cannot starve everyone else.

The pattern is a map of limiters keyed by client identifier.

package main

import (
    "net"
    "net/http"
    "sync"
    "time"

    "golang.org/x/time/rate"
)

type clientLimiter struct {
    limiter  *rate.Limiter
    lastSeen time.Time
}

type IPRateLimiter struct {
    clients map[string]*clientLimiter
    mu      sync.Mutex
    rate    rate.Limit
    burst   int
}

func NewIPRateLimiter(r rate.Limit, b int) *IPRateLimiter {
    rl := &IPRateLimiter{
        clients: make(map[string]*clientLimiter),
        rate:    r,
        burst:   b,
    }
    // Janitor: evict idle clients every minute
    go rl.cleanup()
    return rl
}

func (rl *IPRateLimiter) getLimiter(ip string) *rate.Limiter {
    rl.mu.Lock()
    defer rl.mu.Unlock()

    c, ok := rl.clients[ip]
    if !ok {
        lim := rate.NewLimiter(rl.rate, rl.burst)
        rl.clients[ip] = &clientLimiter{limiter: lim, lastSeen: time.Now()}
        return lim
    }
    c.lastSeen = time.Now()
    return c.limiter
}

func (rl *IPRateLimiter) cleanup() {
    for {
        time.Sleep(time.Minute)
        rl.mu.Lock()
        for ip, c := range rl.clients {
            if time.Since(c.lastSeen) > 3*time.Minute {
                delete(rl.clients, ip)
            }
        }
        rl.mu.Unlock()
    }
}

func (rl *IPRateLimiter) Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // RemoteAddr is host:port; we want the host as the key
        host, _, err := net.SplitHostPort(r.RemoteAddr)
        if err != nil {
            host = r.RemoteAddr
        }
        if !rl.getLimiter(host).Allow() {
            http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
            return
        }
        next.ServeHTTP(w, r)
    })
}

image

A few things to note. First, the janitor goroutine: without it the map grows forever as new IPs keep showing up.

Second, r.RemoteAddr is host:port, so if you use it as the map key directly, the same client on a different ephemeral port gets a fresh bucket — which is not what you want. net.SplitHostPort fixes that.

Third, in any real deployment behind a load balancer or CDN, even the host portion of RemoteAddr is your proxy’s IP — you need to extract the client IP from X-Forwarded-For or X-Real-IP (and verify the proxy is trusted, or you have just made spoofing trivial).

Wait-style: throttling outbound calls

When you are the client hitting some upstream API with a 100 req/sec ceiling, you want to slow down, not error out.

Wait is the right tool here.

func fetchAll(ctx context.Context, urls []string) {
    // ~100/sec, burst of 1 (no headroom beyond the steady rate)
    limiter := rate.NewLimiter(rate.Every(10*time.Millisecond), 1)

    var wg sync.WaitGroup
    for _, u := range urls {
        if err := limiter.Wait(ctx); err != nil {
            break // context cancelled
        }
        wg.Add(1)
        go func(u string) {
            defer wg.Done()
            fetch(u)
        }(u)
    }
    wg.Wait()
}

image

Two things worth flagging in this snippet.

First, burst of 1 means a single token sits in the bucket, so the first call returns immediately and the rest are spaced ~10ms apart.

A burst of 0 would reject everything and Wait would block forever — 1 is the minimum useful value when you want “no real burst headroom.”

Second, the Wait has to happen before the goroutine spawns, not inside it. If you put it inside, you launch a thousand goroutines in a microsecond and they all race through Wait together — you have throttled goroutine starts, but the actual HTTP calls all fire as soon as their tokens come in, which is not the same as throttling the calls.

Doing the wait in the calling loop and then spawning is what gives you actual rate-limited outbound traffic.

rate.Every(d) is a small helper that converts a desired interval into a rate.Limit. rate.Every(10*time.Millisecond) is the same as rate.Limit(100) but reads more naturally when you are thinking in terms of “one request every N milliseconds.”

2. Leaky bucket with go.uber.org/ratelimit

Token bucket allows bursts.

Sometimes you do not want that.

If you are calling an upstream that absolutely cannot tolerate spikes — say, a partner API that throttles you on the millisecond, not the second — you want a leaky bucket: smooth, evenly-spaced output.

Uber’s go.uber.org/ratelimit is the simplest leaky-bucket implementation in the Go ecosystem. The whole API is one method: Take().

go get go.uber.org/ratelimit
package main

import (
    "fmt"
    "time"

    "go.uber.org/ratelimit"
)

func main() {
    rl := ratelimit.New(100) // 100 ops/sec, evenly spaced

    prev := time.Now()
    for i := 0; i < 10; i++ {
        now := rl.Take()
        fmt.Println(i, now.Sub(prev))
        prev = now
    }
}

image

If you run this, every iteration after the first will print roughly 10ms.

The limiter does not give you 100 in a burst and then make you wait — it spaces them out exactly.

That is the leaky bucket guarantee.

Slack: a controlled amount of burstiness

Pure leaky bucket can be too strict.

If your producer is slightly bursty by nature, you may not want every single hiccup to cause queueing.

Uber’s library has a “slack” knob for this.

With slack, the limiter can accumulate a small number of unspent requests during idle periods and let you burn through them in a burst later.

Worth being precise about what slack is, because it is not the same thing as a token bucket’s burst capacity.

A token bucket refills continuously up to its capacity, so even a steady stream of requests can build up headroom if it briefly outpaces the consumer.

Slack only accumulates during idle time — if you are calling Take() continuously, slack does nothing.

It is a one-shot “you went quiet, so we will let you catch up” allowance, not an ongoing buffer.

// Default: allows up to 10 slack tokens (small built-in burst tolerance after idle)
rl := ratelimit.New(100)

// Strict mode: zero slack, perfectly even spacing
rl := ratelimit.New(100, ratelimit.WithoutSlack)

// Custom slack
rl := ratelimit.New(100, ratelimit.WithSlack(50))

image

Note that WithoutSlack is a variable, not a function — no parentheses.

Per-minute and other windows

By default, New(n) means “n per second.” If you want per-minute or per-hour, use Per:

rl := ratelimit.New(5, ratelimit.Per(time.Minute)) // 5 per minute

When to pick Uber’s library over x/time/rate

The honest answer is: only when you specifically need leaky-bucket semantics. x/time/rate can do almost everything Uber’s library does and more (context support, Allow/Reserve semantics, dynamic rate changes).

But if your requirement is “evenly spaced output, no bursts,” ratelimit.New(n) is one line and you are done.

One caveat: the library is stable and widely used, but not actively iterated on.

That is usually fine for something this small and well-defined — but if you want a library with more momentum behind it, x/time/rate is the safer pick.

3. Sliding window: when “N per minute” really means N

Token bucket and leaky bucket reason about rate.

They give you very good average-rate enforcement, but they do not give you an exact “no more than N requests in any rolling 60-second window” guarantee.

Sometimes that exact-count semantics is what you actually need — billing limits, login attempt windows, fairness across tenants.

The clean way to do this single-node is to keep a sorted slice of request timestamps per key, and on each request: evict timestamps older than the window, then check if the count is under the limit. Here is a self-contained implementation:

package ratelimit

import (
    "sort"
    "sync"
    "time"
)

type SlidingWindow struct {
    limit    int
    window   time.Duration
    requests map[string][]time.Time
    mu       sync.Mutex
}

func NewSlidingWindow(limit int, window time.Duration) *SlidingWindow {
    return &SlidingWindow{
        limit:    limit,
        window:   window,
        requests: make(map[string][]time.Time),
    }
}

func (sw *SlidingWindow) Allow(key string) bool {
    sw.mu.Lock()
    defer sw.mu.Unlock()

    now := time.Now()
    cutoff := now.Add(-sw.window)

    // Timestamps are appended in order, so we can binary-search the cutoff.
    timestamps := sw.requests[key]
    i := sort.Search(len(timestamps), func(i int) bool {
        return timestamps[i].After(cutoff)
    })
    timestamps = timestamps[i:]

    if len(timestamps) >= sw.limit {
        sw.requests[key] = timestamps
        return false
    }

    sw.requests[key] = append(timestamps, now)
    return true
}

Usage:

sw := NewSlidingWindow(100, time.Minute) // 100 requests per rolling minute

if !sw.Allow("user:42") {
    // reject
}

image

This is the “sliding log” variant — exact, but memory is O(limit × active_keys).

For high limits (say, 10k req/min across many keys), that becomes real memory.

The standard fix is the sliding window counter variant: keep two adjacent fixed windows (the current and previous minute), each with a single integer count, and estimate the rolling count as count_current + count_previous × (1 - elapsed_fraction_of_current_window).

You lose a bit of precision near window boundaries but drop from O(limit) per key to O(1).

For most real workloads, the simple log version above is fine; reach for the counter version when memory becomes a problem.

You also want a janitor here, same as the IP limiter, to evict keys nobody has hit in a while.

A word on distributed rate limiting

Everything above runs in a single process.

The moment you scale to two instances of your service behind a load balancer, your limits are effectively doubled — each instance has its own bucket.

For a single-instance side project this does not matter.

For anything serious, you need a shared store.

The standard answer is Redis.

The github.com/go-redis/redis_rate package implements GCRA (a leaky-bucket variant) on top of Redis with a single Lua script per check, which keeps it atomic and fast. Roughly:

limiter := redis_rate.NewLimiter(rdb)
res, _ := limiter.Allow(ctx, "user:42", redis_rate.PerSecond(10))
if res.Allowed == 0 {
    // reject
}

I will cover the Redis side of this — including the Lua scripts, why GCRA wins over naive sliding windows at scale, and how to handle Redis going down — in a follow-up post.

For now, just know that when you outgrow single-node limiting, this is the next stop.

Which one should you use?

If you remember one thing from this post, remember this:

  • Default to golang.org/x/time/rate. It is well-tested, context-aware, and covers 90% of cases. Use Allow for HTTP servers, Wait for outbound clients.
  • Reach for go.uber.org/ratelimit when you specifically need evenly spaced output and no bursts.
  • Roll a sliding window when your requirement is genuinely “no more than N in any rolling window of duration W” and average-rate enforcement is not enough.

The biggest mistake I see is people writing their own token bucket from scratch and getting the math subtly wrong — off-by-one on the burst size, races on the refill, drift over long runs. The libraries exist. Use them.

Now go put a 429 in front of whatever is currently melting your server.

AI agents write code fast. They also silently remove logic, change behavior, and introduce bugs — without telling you. You often find out in production.

git-lrc fixes this. It hooks into git commit and reviews every diff before it lands. 60-second setup. Completely free.*

Any feedback or contributors are welcome! It’s online, source-available, and ready for anyone to use.

⭐ Star it on GitHub:

GitHub logo

HexmosTech
/
git-lrc

Free, Micro AI Code Reviews That Run on Commit

AI agents write code fast. They also silently remove logic, change behavior, and introduce bugs — without telling you. You often find out in production.

git-lrc fixes this. It hooks into git commit and reviews every diff before it lands. 60-second setup. Completely free.

See It In Action

See git-lrc catch serious security issues such as leaked credentials, expensive cloud
operations, and sensitive material in log statements

git-lrc-intro-60s.mp4

Why

  • 🤖 AI agents silently break things. Code removed. Logic changed. Edge cases gone. You won’t notice until production.
  • 🔍 Catch it before it ships. AI-powered inline comments show you exactly what changed and what looks wrong.
  • 🔁 Build a…

Understanding Reinforcement Learning with Human Feedback Part 3: Collecting Human Preferences

In the previous article we explored the concept of aligning the pretrained model. Now, we will look at the next component: human preference collection.

The first step in understanding RLHF is to understand that, given a specific prompt, a model can generate different responses.

One way to generate a response is to configure the model to always select the token with the highest output value at every step.

In this case, the model will generate the same response every single time for a given prompt.

Generating Different Responses

Instead of always selecting the highest-value token, we can also use the outputs of the softmax function as probabilities for selecting tokens.

In this approach:

  • the token with the highest probability is more likely to be selected
  • but other tokens still have a chance of being selected

As a result, the model can generate different responses for the same prompt.

Collecting Human Preferences

Since a model can generate multiple responses, we can create pairs of responses for the same prompt.

We can then ask people which response they prefer.

For example, given two possible answers, a person can simply choose the better one.

Collecting preferences like this is much faster than asking people to manually write responses for every prompt.

This preference collection process is the “Human Feedback” part of RLHF.

Using Preference Data

Once we collect preference data, we can use it to train the model so that it assigns higher scores to preferred responses and lower scores to less preferred ones.

Over time, this helps the model generate responses that better match human preferences.

In the next article, we will explore how to train the model using this preference data.

Looking for an easier way to install tools, libraries, or entire repositories?
Try Installerpedia: a community-driven, structured installation platform that lets you install almost anything with minimal hassle and clear, reliable guidance.

Just run:

ipm install repo-name

… and you’re done! 🚀

Installerpedia Screenshot

🔗 Explore Installerpedia here