Application Services vs Use Cases: The Actual Difference

ApplicationService as one wide box holding multiple verbs versus a row of single-verb UseCase stamps

Two PHP engineers are reviewing a pull request. One says “this should be an application service.” The other says “no, a use case.” They argue for fifteen minutes, agree they mean the same thing, merge the PR, and the next sprint someone opens app/Services/OrderService.php and finds nineteen public methods, two private helpers, and four injected dependencies that only two of those methods actually use.

That argument repeats in every PHP codebase that imports clean or hexagonal vocabulary without locking down what the words mean. The terms are not interchangeable. They describe two different things at two different sizes. Once a team agrees on the distinction, “where does this code go” stops being a debate.

Here is the rule that ends it: an ApplicationService is a layer that holds related operations. A UseCase is a single operation: one verb, one class, one public method. Both are valid. Picking one is a project-wide decision, not a per-feature one.

What the two words actually point at

Eric Evans introduced application services in Domain-Driven Design (2003) as the thin orchestration layer that sits between the inbound side (controllers, CLI, queue workers) and the domain. Robert Martin used use cases in Clean Architecture (2017) for the same orchestration responsibility, but framed each one as its own object. Both authors agree on what the code does. They disagree on how to package it.

In a PHP codebase the difference shows up at the file level.

An ApplicationService groups verbs that share a noun:

final class OrderApplicationService
{
    public function __construct(
        private readonly OrderRepository $orders,
        private readonly PaymentGateway $payments,
        private readonly EventDispatcher $events,
        private readonly Clock $clock,
    ) {}

    public function create(CreateOrderCommand $cmd): OrderId { /* ... */ }
    public function cancel(CancelOrderCommand $cmd): void    { /* ... */ }
    public function refund(RefundOrderCommand $cmd): void    { /* ... */ }
}

A UseCase isolates one verb in its own class:

final class CreateOrderUseCase
{
    public function __construct(
        private readonly OrderRepository $orders,
        private readonly Clock $clock,
    ) {}

    public function execute(CreateOrderCommand $cmd): OrderId { /* ... */ }
}

The orchestration, dependency set, and domain boundary are identical. Only the packaging changes. That is the whole disagreement.

ApplicationService as one wide box holding multiple verbs versus a row of single-verb UseCase stamps

The worked example: three verbs, two packagings

Here is the same order feature written both ways, in PHP 8.3, against the same domain. The domain types and ports are constant across both versions; only the application layer changes.

Shared pieces first.

<?php
declare(strict_types=1);

namespace AppDomainOrder;

final readonly class OrderId
{
    public function __construct(public string $value) {}
}

final class Order
{
    public function __construct(
        public readonly OrderId $id,
        public readonly string $customerId,
        public readonly int $totalCents,
        public readonly DateTimeImmutable $createdAt,
        private string $status,
    ) {}

    public function cancel(): void
    {
        if ($this->status !== 'pending') {
            throw new DomainException(
                "cannot cancel order in status {$this->status}"
            );
        }
        $this->status = 'cancelled';
    }

    public function refund(): void
    {
        if ($this->status !== 'paid') {
            throw new DomainException(
                "cannot refund order in status {$this->status}"
            );
        }
        $this->status = 'refunded';
    }

    public function status(): string
    {
        return $this->status;
    }
}

Two ports, one for persistence and one for the payment side.

<?php
declare(strict_types=1);

namespace AppApplicationPort;

use AppDomainOrderOrder;
use AppDomainOrderOrderId;

interface OrderRepository
{
    public function save(Order $order): void;
    public function get(OrderId $id): Order;
}

interface PaymentGateway
{
    public function refund(OrderId $id, int $amountCents): void;
}

Three commands, one per verb.

<?php
declare(strict_types=1);

namespace AppApplicationCommand;

final readonly class CreateOrderCommand
{
    public function __construct(
        public string $customerId,
        public int $totalCents,
    ) {}
}

final readonly class CancelOrderCommand
{
    public function __construct(public string $orderId) {}
}

final readonly class RefundOrderCommand
{
    public function __construct(public string $orderId) {}
}

Version A — one ApplicationService class with three verbs

<?php
declare(strict_types=1);

namespace AppApplicationService;

use AppApplicationCommandCancelOrderCommand;
use AppApplicationCommandCreateOrderCommand;
use AppApplicationCommandRefundOrderCommand;
use AppApplicationPortOrderRepository;
use AppApplicationPortPaymentGateway;
use AppDomainClock;
use AppDomainOrderOrder;
use AppDomainOrderOrderId;

final class OrderApplicationService
{
    public function __construct(
        private readonly OrderRepository $orders,
        private readonly PaymentGateway $payments,
        private readonly Clock $clock,
    ) {}

    public function create(CreateOrderCommand $cmd): OrderId
    {
        $order = new Order(
            id:         new OrderId(bin2hex(random_bytes(8))),
            customerId: $cmd->customerId,
            totalCents: $cmd->totalCents,
            createdAt:  $this->clock->now(),
            status:     'pending',
        );
        $this->orders->save($order);
        return $order->id;
    }

    public function cancel(CancelOrderCommand $cmd): void
    {
        $order = $this->orders->get(new OrderId($cmd->orderId));
        $order->cancel();
        $this->orders->save($order);
    }

    public function refund(RefundOrderCommand $cmd): void
    {
        $order = $this->orders->get(new OrderId($cmd->orderId));
        $order->refund();
        $this->payments->refund($order->id, $order->totalCents);
        $this->orders->save($order);
    }
}

Notice what this version pays: every method has access to every constructor dependency, whether it needs it or not. create and cancel never touch $this->payments, but it is wired in regardless. That is the trade-off: convenience of grouping in exchange for looser dependency hygiene.

Version B — three UseCase classes, one verb each

<?php
declare(strict_types=1);

namespace AppApplicationUseCase;

use AppApplicationCommandCreateOrderCommand;
use AppApplicationPortOrderRepository;
use AppDomainClock;
use AppDomainOrderOrder;
use AppDomainOrderOrderId;

final class CreateOrderUseCase
{
    public function __construct(
        private readonly OrderRepository $orders,
        private readonly Clock $clock,
    ) {}

    public function execute(CreateOrderCommand $cmd): OrderId
    {
        $order = new Order(
            id:         new OrderId(bin2hex(random_bytes(8))),
            customerId: $cmd->customerId,
            totalCents: $cmd->totalCents,
            createdAt:  $this->clock->now(),
            status:     'pending',
        );
        $this->orders->save($order);
        return $order->id;
    }
}
<?php
declare(strict_types=1);

namespace AppApplicationUseCase;

use AppApplicationCommandCancelOrderCommand;
use AppApplicationPortOrderRepository;
use AppDomainOrderOrderId;

final class CancelOrderUseCase
{
    public function __construct(
        private readonly OrderRepository $orders,
    ) {}

    public function execute(CancelOrderCommand $cmd): void
    {
        $order = $this->orders->get(new OrderId($cmd->orderId));
        $order->cancel();
        $this->orders->save($order);
    }
}
<?php
declare(strict_types=1);

namespace AppApplicationUseCase;

use AppApplicationCommandRefundOrderCommand;
use AppApplicationPortOrderRepository;
use AppApplicationPortPaymentGateway;
use AppDomainOrderOrderId;

final class RefundOrderUseCase
{
    public function __construct(
        private readonly OrderRepository $orders,
        private readonly PaymentGateway $payments,
    ) {}

    public function execute(RefundOrderCommand $cmd): void
    {
        $order = $this->orders->get(new OrderId($cmd->orderId));
        $order->refund();
        $this->payments->refund($order->id, $order->totalCents);
        $this->orders->save($order);
    }
}

Three files instead of one. Each class names exactly what it needs. CancelOrderUseCase has no idea PaymentGateway exists; CreateOrderUseCase does not pull in either of the other dependencies. The signature of each constructor is a precise inventory of the verb.

Side-by-side: what each style costs and pays for

Table-style comparison of grouping by noun versus splitting by verb, with check and cross marks against six properties

Property ApplicationService (layer) UseCase (per verb)
File count 1 per noun N per noun
Constructor scope Union of all verbs’ deps Exactly this verb’s deps
Where readers look first The class The folder listing
Testing setup Mock everything per test Mock only what this verb needs
Refactoring a single verb Touches a shared class Touches one isolated class
New verb on the same noun Add a method Add a file
Container wiring One binding per noun One binding per verb
Risk of God-class drift Real, watch for it Structurally hard
Risk of folder bloat Low Real at 50+ verbs
CQRS / handler buses fit Awkward (1-to-many) Native (1-to-1)

Neither column is wrong. The right column is what Robert Martin draws on the whiteboard; the left column is what most Laravel and Symfony tutorials write. Teams get into trouble when half the codebase is one and half is the other.

How to pick one and stop arguing

Use ApplicationService when:

  • The team is small and the surface area is small. Three to six verbs per noun, no plans to grow much.
  • You are not running a command bus or a CQRS-style handler discovery. The orchestration entry point is the controller, and one injected service per noun is easier to wire than ten.
  • The verbs share a lot of state-validation logic and pulling it into private methods on the same class is cleaner than extracting a shared collaborator.
  • The team is new to clean/hexagonal vocabulary and you want a forgiving starting layer that won’t explode into hundreds of files in week two.

Use UseCase when:

  • The team uses a command bus (Symfony Messenger, Tactician, a homemade one) and each command needs a single handler. The 1-to-1 mapping is exactly what these libraries expect.
  • Constructor hygiene matters more than file count. Reviewers should see at a glance which ports a single operation touches.
  • The codebase has fifty or more verbs across a few nouns and the would-be OrderApplicationService is heading toward a thousand-line file with thirty dependencies.
  • You want a one-screen unit of work that can be reasoned about, tested, and replaced in isolation.

The team makes one of those calls, writes it into the project’s architecture doc, and stops debating it per-PR.

Two anti-patterns that ruin both styles

The God service. An OrderApplicationService with twenty-two public methods, fourteen injected dependencies, and a private function ensureOrderIsValid() that two-thirds of the methods call. This is the version everyone has met, and it is the reason people write angry blog posts about service layers. Renaming to UseCase changes nothing. The fix is to split the noun (OrderLifecycle, OrderRefunds, OrderFulfilment) or go per-verb.

The shadow service. A folder of UseCase classes where every class extends a BaseUseCase that injects a ServiceContainer and pulls dependencies out of it. The constructor lists nothing; the body calls $this->container->get(OrderRepository::class). This is an ApplicationService wearing a UseCase costume. It defeats the whole point: the constructor no longer tells you what the verb needs, and tests have to stub a container instead of passing two arguments. If the team picks UseCase, the constructor lists the real ports. Otherwise, pick ApplicationService and stop pretending.

Both anti-patterns come from the same place: treating the naming choice as decoration instead of a structural commitment. The structural commitment is what matters.

What survives across both styles

Whatever you pick, the rules below hold:

  • The application layer never imports from the framework. No Eloquent models, no Symfony attributes, no Laravel facades. The application orchestrates the domain through ports.
  • The domain knows nothing about the application layer. Entities and value objects sit one ring further in and stay there.
  • One command class per verb, no matter whether the verb lives in its own UseCase or as a method on an ApplicationService. The command is the contract; the handler is the implementation.
  • Controllers and CLI commands and queue workers are inbound adapters. They translate transport-level input into a command and call the application. They do not contain business logic.
  • Ports live with the application layer (or the domain), not with the adapters that implement them. The application names the shape of what it needs; infrastructure conforms.

If those five rules hold, you can swap an OrderApplicationService for three UseCase classes in an afternoon without touching the domain, the controllers, or the tests beyond their setup blocks. The two styles are swap-compatible inside the same architecture. They are not swap-compatible in everyday code review. That is the conversation worth having on your team this week.

If this was useful

The “where does this code go” question is half of what makes hexagonal and clean architecture feel hard in PHP, and naming conventions are the other half. Decoupled PHP walks the full project shape (domain, ports, application, adapters) with both architectures side by side, in PHP 8.3, with a public examples repo for each chapter. If your team has been arguing about service-versus-use-case for longer than is healthy, the book picks a side and shows the trade.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Supervising AI Coding Agents from Anywhere on Earth: Why I’m Building Cipher

I decided to build AR Glasses for AI Coding Because I Was Trapped at My Desk

The 45-Minute Problem

I was coding at my desk when my AI agent hit a blocker.

I wanted to walk away. Grab coffee. Stretch. Literally anything but sit there staring at a progress bar.

I couldn’t.

I sat there for 45 minutes babysitting it — watching a progress bar I couldn’t leave.

This happens to developers constantly. AI made coding faster. But it trapped us at our desks.

The Productivity Trap

AI coding tools solved the speed problem.

They created a new one: the productivity trap.

The agent runs. You’re stuck watching it. You can’t leave. You can’t multitask. You just… wait.

Remote Supervision Exists (But It’s Broken)

Here’s the thing: remote AI supervision already exists.

Claude Code launched mobile capability. You can technically supervise AI agents from your phone.

The capability is real. The experience is punishment.

Why?

📱 Tiny screens – Squinting at code on a phone is not supervision. It’s torture.

⌨️ No keyboard – Typing corrections on mobile kills every productivity gain.

🔲 No visual context – Text-only output strips the spatial understanding developers rely on.

Enter: Cipher

So I decided to build Cipher – AR glasses for supervising AI coding agents from anywhere.

Here’s how it works:

👓 AR Glasses

See code in context, not just raw text on a tiny display. Visual overlays. Spatial context. The way you’d see it on a monitor.

🎙️ Voice Control

Speak instructions. No keyboard. No friction. Natural language commands to your agent team.

🤖 Multi-Agent Team

Not one generalist AI. 7 specialist agents running in parallel:

  • 🏛️ ATLAS – Architecture specialist
  • 🔐 CYPHER – Security expert
  • ⚒️ FORGE – Code generation
  • 🔬 SAGE – Testing & QA
  • 🚀 SWIFT – DevOps
  • 🗄️ ORACLE – Data & SQL
  • 🎨 PIXEL – Frontend & UI

Each agent has deep domain expertise. They cross-validate each other. Higher quality output than a single generalist.

🌍 Hands-Free

Supervise while walking. Coffee shop. Park bench. Anywhere. Same capability. 10× better experience.

What’s Working Today

I have a working proof-of-concept (phone app):

✅ 7 specialist AI agents orchestrated in real time (CrewAI coordinating via Groq models)

✅ Sub-1.5 second latency globally over internet architecture

✅ Real coding tasks completed successfully end-to-end

✅ Daily personal use – I run this on my own projects

The backend is proven. The agent system is functional. The hard engineering is behind us.

The glasses are a better interface on top of a working foundation.

The Roadmap

Phase 1 – Phone App Beta (Next few months)

Launch to waitlist users. Pay-as-you-go API pricing. Validate demand. Gather usage data.

Phase 2 – Hardware Development (next 8-10 months)

Build AR glasses prototype:

  • Display: 0.39″ 1920×1080 OLED (5644 PPI)
  • Chipset: ESP32-S3
  • Camera: OV5640 5MP
  • ODM partner: Devomech Solutions ($50K quote received)

Phase 3 – Kickstarter Launch (If we find a US-based co-founder)

Pre-orders from phone app users. Validate hardware demand

Phase 4 – Manufacturing & Scale

Deliver to backers. Subscription model for glasses users.

The Contingency

Hardware is hard. If the glasses take longer than expected, I’m not stuck.

Pivot path: Ship the phone app as a standalone product.

Multi-agent supervision on mobile is still valuable and differentiated.

The glasses are the 10x experience. The phone app is the 3x experience.

Both are defensible.

About Me

I’m 17. I’m a full-stack developer. I started coding at 14. I had an ML engineering internship at 15 (NYC-based company, remote).

I AM the target customer. I built Cipher because I needed it.

The phone app comes first.

Join the Waitlist

I’m launching the phone app beta in a few months.

AR glasses prototype in early 2027.

*Join the waitlist for early access: *https://tally.so/r/rjGk9R

Building in public. Feedback welcome.

Questions for the Community

  1. Does this problem resonate with you? Do you feel stuck babysitting AI agents?
  2. Would you use a phone app for this, or do you think glasses are essential?
  3. What’s your biggest concern with the multi-agent approach?

Drop your thoughts in the comments. All feedback welcome – especially critical feedback.

Let’s build this together.

Junior devs using Copilot are speedrunning their own irrelevance

There’s a post floating around dev forums from a junior engineer that stopped me cold. The essence of it was: “I can sense myself turning into an imbecile.”

Not burnout. Not imposter syndrome. Something new. A slow, creeping sense that the tools doing the work for them are hollowing out the skills they never got to build.

That should scare the entire industry.

The Speed Trap

Copilot and Cursor adoption among junior engineers in 2025 is massive. Almost everyone coming up right now is using AI-assisted coding from day one. And on the surface, it looks like a miracle. Juniors are shipping faster than ever.

However, delivery is not learning. Fast learning is very similar to speed without understanding, simply heading towards a wall.

At the beginning of my coding journey I inevitably had to sit with bugs. An hour staring at a stack trace would lead me to grok something elemental about the system. That friction was the education. AI assistants remove the friction and the education with it.

Autocomplete Isn’t Mentorship

Here’s what I keep seeing play out. A junior dev gets stuck, tabs over to Copilot, accepts a suggestion, and moves on. The code functions as expected. The PR is merged. There is no inquiry about whether or not the dev could explain why it worked.

→ They learn to prompt, not to reason.
→ They learn to accept, not to evaluate.
→ They learn to ship, not to debug.

Studies and widespread anecdotal evidence are pointing the same direction: heavy AI dependency correlates with weaker skill retention. People who lean on autocomplete for everything struggle more when the autocomplete disappears. This isn’t shocking. It’s scary.

The junior who said “I felt myself becoming an idiot” was not dramatic. They were accurate about the feedback loop we’re in, where output is rewarded and curiosity is punished. 🎯

The Industry’s Blind Spot

Experienced engineers and engineering managers are excited about the increase in productivity. It’s only natural. Juniors are closing tickets faster, asking fewer questions, and seemingly ramping up in record time.

But “ramping up” and “getting good” aren’t the same thing. We’re confusing onboarding speed with competence. And when those juniors hit a problem that Copilot can’t pattern-match — a weird race condition, a subtle data corruption bug, an architecture decision that requires actual judgment — they’re stuck.

Stuck not at “I need to Google this”. Stuck at “I don’t even know what question to ask”.

This gap is fundamentally different, and it actually grows the longer someone continues to exclusively code through AI suggestions.

This Isn’t Anti-Tool. It’s Anti-Autopilot.

I use Copilot. I use Cursor. I’m not a luddite yelling at clouds. These tools are genuinely powerful for experienced devs who already have mental models and can evaluate suggestions critically.

But for someone who’s never built those mental models? The tool becomes a crutch before it becomes a superpower. 😬

→ Copilot is a force multiplier — but you can’t multiply zero.
→ If you skip the struggle phase, you skip the part where intuition forms.
→ The best junior devs I’ve worked with deliberately turn AI off for learning tasks.

The final point is the most important. The ones who are going to thrive are the ones who treat AI like a power tool, not a brain replacement. They will utilize it only after they have grasped the problem, and not in place of grasping the problem.

What Actually Helps

If you’re a junior dev reading this, I’m not telling you to delete Copilot. I’m telling you to build a practice around it.

→ Write the first draft yourself. Then compare it to what AI suggests. The delta is where learning lives.
→ Debug without AI once a week. Sit with the discomfort. That’s the gym for your engineering brain.
→ Read the suggestion before you accept it. Every single time. If you can’t explain it, you didn’t learn it.

If you’re a senior or a manager, stop measuring junior productivity by ticket throughput alone. Instead, request them to provide you with details on the code they’re working on. Collaborate with them. Notice if they’re building judgment or just building features.

The Real Risk

The industry is not experiencing a shortage of junior developers. It’s a shortage of thinking developers and it’s gradually developing. And we are supporting this trend with $20/month subscriptions. 🔥

We are quickly educating a generation of engineers who are capable of writing code but lack the ability to think about systems. This is not an issue with the tools. It is an issue with leadership.

Now, over to you: Do you think that embracing A.I. in our daily lives could be reducing our ability to tackle complex problems without it? When was the last time you solved something hard, and what did you learn from the struggle?

Hive-index now shows dapps activity

Another exciting update to hive-index is now live.

This time it’s a whole new page showing dapps activity.

Previously, hive-index aggregated custom_json operations by their id. This is useful, but a single dapp often shows up under many different ids, which makes it hard to tell how much activity a given dapp actually generates.

The new dapps activity page fixes this. It groups custom JSON activity by known dapp and shows the accumulated counts, relative share, latest block and the 30-day activity for each one. You can also drill down into a dapp to see the breakdown of the top individual ids it’s made of.

Head to https://index.hive-adventures.com/dapps to see it for yourself.

Hopefully you’ll find this useful. Please let me know what you think, and if you have any suggestions or questions, just leave a comment.

Free 35B Multimodal LLM Server on Kaggle GPU — Accessible from Any OpenAI-Compatible Client

The Problem

Running a large language model locally is expensive. A GPU with enough VRAM to run a 35B model costs several thousand dollars. Cloud APIs are convenient, but you pay per token, your data goes through someone else’s servers, and you have no flexibility over the model or its configuration.

At the same time, free cloud GPU platforms like Google Colab and Kaggle exist — but using them as a proper LLM server is not straightforward. Sessions expire, browsers need to stay open, models need to be re-downloaded every time, and tools like Ngrok cut long HTTP connections which breaks token streaming.

The goal was simple: run a powerful open-source multimodal LLM on a free GPU, expose it as a standard API, and connect to it from any machine — without fighting with the platform’s limitations every session.

The Solution

The setup uses three tools working together:

Kaggle as the GPU host — free T4 x2 (30GB VRAM combined), up to 12 hours per session, 30 hours of GPU per week. Enough to run Qwen3.6 35B fully on GPU with no hybrid mode needed.

llama.cpp as the inference engine — the native binary, not a wrapper. It gives precise control over GPU layer offloading via -ngl, exposes a standard OpenAI-compatible HTTP server, and handles multimodal input natively via a separate vision projector file (mmproj).

Cloudflare Quick Tunnel for the public URL — no account required, no request limits, and no timeout on long HTTP connections. This last point is critical: Ngrok’s free tier cuts streaming responses, which makes it unusable for LLM token streaming.

The model is Qwen3.6-35B-A3B, quantized to 4-bit by Unsloth (UD-Q4_K_XL, ~22GB). It supports multimodal input (text + images) and a hybrid thinking mode that can be toggled per request without restarting the server.

Implementation Steps

1. Persistent model storage

The first problem to solve was re-downloading 22GB at every session. The solution: download the model once from HuggingFace directly onto Kaggle’s servers using snapshot_download with pattern filters to grab only the two necessary files — the main model and the mmproj vision projector. Both are saved as a private Kaggle Dataset, mounted read-only in under 10 seconds on every subsequent session.

2. Persistent llama.cpp binaries

Compiling llama.cpp from source with CUDA support takes ~26 minutes. Running this every session was not acceptable. The same approach as the model: compile once, save the binaries (llama-server, llama-cli, llama-mtmd-cli) and their shared libraries (.so files) as a second Kaggle Dataset.

This step had a non-obvious issue: llama-server is dynamically linked against libllama-common.so. Copying only the binary without its .so files causes an immediate crash with cannot open shared object file. The fix was to collect all .so files from the build tree and include them in the dataset, then set LD_LIBRARY_PATH=/kaggle/working before launching the server.

3. CUDA linker fix for Kaggle

The standard cmake -DGGML_CUDA=ON fails on Kaggle with:

/usr/bin/ld: cannot find -lCUDA::cuda_driver

The real libcuda.so on Kaggle lives in /usr/local/nvidia/lib64/ (the GPU driver mount), not where cmake looks by default. The fix is a symlink:

ln -sf /usr/local/nvidia/lib64/libcuda.so /usr/local/cuda/lib64/libcuda.so

Combined with -DCMAKE_PREFIX_PATH=/usr/local/nvidia and -DCMAKE_CUDA_ARCHITECTURES=75 (T4 = sm_75), this produces a working CUDA build.

4. Server startup health check

llama-server returns HTTP 200 with {"status": "loading"} while the model loads, and HTTP 200 with {"status": "ok"} only when truly ready. Checking only the status code causes the server to appear ready before it actually is. The correct check waits for r.json().get("status") == "ok".

5. Thinking mode per request

Qwen3.6 supports a hybrid thinking mode where the model reasons step-by-step before answering. This is controlled via chat_template_kwargs passed in the request body — not as a server startup flag. This means the same running server handles both modes depending on what the client sends:

# Direct mode
extra_body={"chat_template_kwargs": {"enable_thinking": False}}

# Thinking mode
extra_body={"chat_template_kwargs": {"enable_thinking": True}}

No restart needed. Two terminals, two modes, one server.

6. Client script

A small chat.py CLI client handles conversation history, image input via /image path/to/file, and the thinking mode toggle via --thinking. It automatically adjusts temperature and top_p per Unsloth’s official recommendations for each mode.

Challenges

The Kaggle GPU type problem. The Kaggle API (kaggle kernels push) cannot specify the GPU type programmatically. Pushing a new kernel version always allocates whatever GPU is available by default — often a P100 instead of T4 x2. There is an open feature request for this, but no workaround exists via the CLI. The only solution is to open the Kaggle notebook in a browser once per session, manually select GPU T4 x2, and click Run All. Everything else is automated.

The .so dependency chain. The first version of the binaries dataset contained only the executables. The server crashed immediately on every launch with a missing shared library error. Tracking down all the .so files produced by the build and including them in the dataset, combined with setting LD_LIBRARY_PATH in both the Python environment and the subprocess launched by Popen, took several iterations to get right.

Session URL management. The Cloudflare Quick Tunnel URL changes every session. To make it retrievable without keeping the browser open, the server notebook writes the URL to /kaggle/working/server_url.txt as soon as the tunnel starts. This file is accessible via kaggle kernels output from the local machine.

Result

The final setup starts in 5–6 minutes per session: ~5 seconds to copy binaries from the dataset, then model loading time. No redownloading, no recompilation.

The server exposes a fully OpenAI-compatible API. Any client that works with OpenAI works here without modification — Python SDK, LangChain, LlamaIndex, Open WebUI, curl:

from openai import OpenAI

client = OpenAI(
    base_url="https://xxxx.trycloudflare.com/v1",
    api_key="none",
)
response = client.chat.completions.create(
    model="qwen3.6-35b-a3b",
    messages=[{"role": "user", "content": "Hello!"}],
)

With an image:

response = client.chat.completions.create(
    model="qwen3.6-35b-a3b",
    messages=[{
        "role": "user",
        "content": [
            {"type": "text", "text": "What is in this image?"},
            {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}},
        ]
    }],
)

The project is open source. All notebooks are documented cell by cell, and the README covers every client and edge case.

GitHub: [https://github.com/Tahsine/kaggle-llm-server]

Ollama Cheat Sheet: Local LLMs, Models, API & Integration (2026)

TL;DR

  • Ollama runs open LLMs locally: Llama 3.3, Mistral, Gemma, DeepSeek, Qwen, Phi, and vision models
  • ollama run llama3.3 — pull and start in one command
  • REST API on localhost:11434 — chat completions, generate, embeddings
  • Python and JavaScript libraries for integration
  • Modelfile for customizing system prompts, temperature, and context window

Ollama made local LLMs accessible to anyone who could run a Docker container. No Hugging Face model downloads to manage manually. No inference server configuration. ollama run llama3.3 downloads the model and opens a chat session. It has become the standard way to run open models locally, with a library of thousands of community models on top of the official catalog.

Quick Start

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

# Pull and run a model
ollama run llama3.3

# Run a single prompt
ollama run llama3.3 "Explain Docker containers in one sentence."

# REST API
curl http://localhost:11434/api/chat -d '{
  "model": "llama3.3:8b",
  "messages": [{"role": "user", "content": "Hello"}],
  "stream": false
}'

Popular Models (May 2026)

Model Size Memory
llama3.3:70b 70B ~128GB VRAM
llama3.3:8b 8B ~8GB VRAM
deepseek-r1:8b 8B ~8GB VRAM
qwen3:8b 8B ~8GB VRAM
gemma3:4b 4B ~4GB VRAM
phi4:3.8b 3.8B ~4GB VRAM
llava:7b 7B ~12GB VRAM

Python Integration

pip install ollama
import ollama

response = ollama.chat(
    model='llama3.3:8b',
    messages=[
        {'role': 'system', 'content': 'You are a code reviewer.'},
        {'role': 'user', 'content': 'Review: def add(a, b): return a + b'},
    ]
)
print(response['message']['content'])

# Streaming
for chunk in ollama.chat(model='llama3.3:8b',
                          messages=[{'role': 'user', 'content': 'Hello'}]):
    print(chunk['message']['content'], end='', flush=True)

Originally published at meshworld.in

CLAUDE.md for Android and Jetpack Compose: 13 Rules That Make AI Write Modern, Production-Ready Android Code

CLAUDE.md for Android and Jetpack Compose: 13 Rules That Make AI Write Modern, Production-Ready Android Code

Android development has two eras of AI output quality.

The first era: AI that reaches for XML layouts, AsyncTask, SharedPreferences, and findViewById. Code that compiles on Android 14 but was written for Android 4. Code that passes lint but violates every modern architecture guideline.

The second era: AI that defaults to Compose, coroutines, ViewModel, StateFlow, Hilt, and the patterns from the Now in Android reference project.

The difference between these outcomes is a CLAUDE.md that specifies which Android you’re building.

These 13 rules cover the patterns that matter most — the ones where AI drifts to legacy APIs without explicit instruction.

Rule 1: Jetpack Compose only — no XML layouts

UI: Jetpack Compose exclusively. No XML layouts, no View system.
Forbidden: ConstraintLayout XML, LinearLayout, RelativeLayout, findViewById.
Composables: stateless where possible. State hoisted to ViewModel.
Preview: @Preview annotation on every composable component.
Material3 only — not Material2.

AI has seen enormous volumes of XML-based Android code. Without a rule, it will generate setContentView(R.layout.activity_main) and ViewBinding for new screens in a Compose project.

// Banned — XML/View system
class UserFragment : Fragment() {
    private lateinit var binding: FragmentUserBinding
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        binding.nameText.text = viewModel.userName
    }
}

// Required — Jetpack Compose
@Composable
fun UserScreen(
    uiState: UserUiState,
    onAction: (UserAction) -> Unit
) {
    Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
        Text(text = uiState.userName, style = MaterialTheme.typography.headlineMedium)
    }
}

Rule 2: ViewModel + UiState — no logic in Composables

Architecture:
- ViewModel: holds UI state, handles user actions, calls use cases.
- UiState: sealed class or data class representing screen state.
- Composables: render UiState. No business logic. No direct repository calls.
- Events: one-time events (navigation, toasts) via Channel<UiEvent>.
- StateFlow<UiState> exposed from ViewModel. Collected with collectAsStateWithLifecycle().

AI generates Composables that call repositories directly, fetch data in LaunchedEffect without a ViewModel, and hold mutable state locally for things that should survive rotation.

// Wrong — business logic and state in Composable
@Composable
fun UserScreen(repository: UserRepository) {
    var userName by remember { mutableStateOf("") }
    LaunchedEffect(Unit) {
        userName = repository.getUser().name  // Direct repo call, no ViewModel
    }
    Text(text = userName)
}

// Right — ViewModel owns state
@HiltViewModel
class UserViewModel @Inject constructor(
    private val getUserUseCase: GetUserUseCase
) : ViewModel() {
    private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()

    init { loadUser() }

    private fun loadUser() = viewModelScope.launch {
        _uiState.value = getUserUseCase().fold(
            onSuccess = { UserUiState.Content(it) },
            onFailure = { UserUiState.Error(it.message) }
        )
    }
}

Rule 3: Coroutines and Flow — no callbacks, no RxJava

Async: Kotlin coroutines everywhere. No callbacks. No RxJava. No AsyncTask.
Repositories: return Flow<T> for streams, suspend fun for one-shot operations.
Dispatchers: IO for network/disk, Default for CPU-intensive. Never Main for background work.
viewModelScope: use for ViewModel coroutines. Never GlobalScope.
Cancellation: coroutines are cooperative — check cancellation in long loops.

AI generates callback-style code and sometimes RxJava (from older training data). Both are banned in modern Android.

// Banned — callback style
fun getUser(id: String, callback: (User) -> Unit) {
    thread { callback(api.getUser(id)) }  // No cancellation, no error handling
}

// Required — coroutines
suspend fun getUser(id: String): Result<User> = withContext(Dispatchers.IO) {
    runCatching { api.getUser(id) }
}

// Repository stream
fun observeMessages(): Flow<List<Message>> = messageDao.observeAll()
    .flowOn(Dispatchers.IO)
    .catch { emit(emptyList()) }

Rule 4: Hilt for dependency injection — no manual DI, no Koin

DI: Hilt exclusively.
Modules: @InstallIn with appropriate component scope.
ViewModel: @HiltViewModel + @Inject constructor.
Application: @HiltAndroidApp on Application class.
No: manual object graphs, service locators, companion object getInstance().

AI generates singleton patterns and manual DI because they’re simpler to write inline. Hilt makes dependencies explicit, scoped, and testable.

// Banned — manual singleton
object NetworkClient {
    val api: ApiService by lazy { Retrofit.Builder()... }
}

// Required — Hilt module
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides @Singleton
    fun provideApiService(client: OkHttpClient): ApiService =
        Retrofit.Builder().client(client).baseUrl(BASE_URL).build()
            .create(ApiService::class.java)
}

Rule 5: Repository pattern — data layer is abstract

Data layer:
- Repository interface in domain layer. Implementation in data layer.
- Repositories return domain models — never network DTOs or Room entities.
- Mapping: explicit mappers between layers (DTO → Domain, Entity → Domain).
- Local-first: Room as source of truth. Network syncs to Room. UI observes Room.
- No direct Retrofit calls from ViewModel or use cases.
// Domain layer — interface
interface UserRepository {
    fun observeUser(id: String): Flow<User>
    suspend fun syncUser(id: String): Result<Unit>
}

// Data layer — implementation
class UserRepositoryImpl @Inject constructor(
    private val api: UserApi,
    private val dao: UserDao,
    private val mapper: UserMapper
) : UserRepository {
    override fun observeUser(id: String): Flow<User> =
        dao.observeById(id).map(mapper::toDomain)

    override suspend fun syncUser(id: String): Result<Unit> = runCatching {
        val dto = api.getUser(id)
        dao.upsert(mapper.toEntity(dto))
    }
}

Rule 6: Room for local persistence — no SharedPreferences for structured data

Local data:
- Room for all structured data. No raw SQLite.
- SharedPreferences / DataStore: only for simple key-value preferences (theme, onboarding flag).
- DataStore (Proto or Preferences): replace SharedPreferences for new code.
- Room DAOs: return Flow<T> for observed queries, suspend fun for mutations.
- Migrations: explicit RoomDatabase.Migration — never destructive migration in production.
@Dao
interface UserDao {
    @Query("SELECT * FROM users WHERE id = :id")
    fun observeById(id: String): Flow<UserEntity?>

    @Upsert
    suspend fun upsert(user: UserEntity)

    @Query("DELETE FROM users WHERE id = :id")
    suspend fun deleteById(id: String)
}

Rule 7: Navigation — Jetpack Navigation Compose with typed routes

Navigation:
- Jetpack Navigation Compose with type-safe routes (Navigation 2.8+).
- Routes: sealed class or @Serializable data class — no string literals.
- Single NavHost per feature graph. Nested graphs for feature modules.
- Navigate from ViewModel via UiEvent channel — not directly from Composable.
- Deep links: declared in NavGraph, not hardcoded in AndroidManifest.

Rule 8: State management — immutable UiState, unidirectional data flow

State rules:
- UiState: immutable data class. Use copy() for updates.
- MutableStateFlow inside ViewModel, exposed as StateFlow (read-only).
- No mutableStateOf in ViewModel — use MutableStateFlow.
- Composables: collect with collectAsStateWithLifecycle() (lifecycle-aware).
- No shared mutable state between ViewModels.
data class SearchUiState(
    val query: String = "",
    val results: List<SearchResult> = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null
)

// In ViewModel
_uiState.update { it.copy(isLoading = true) }

Rule 9: Testing — ViewModel unit tests, Composable screenshot tests

Testing:
- ViewModel: JUnit 5 + MockK + Turbine (for Flow testing). No Android dependencies.
- Repository: JUnit 5 + MockK for unit tests. Room in-memory DB for integration.
- Composables: Compose UI test (composeTestRule) or Paparazzi for screenshot tests.
- No Robolectric for new tests — prefer pure JVM or instrumented tests.
- coroutinesTestRule: StandardTestDispatcher for deterministic coroutine tests.
@Test
fun `search query updates state`() = runTest {
    val viewModel = SearchViewModel(mockRepository)
    viewModel.uiState.test {
        assertThat(awaitItem().query).isEmpty()
        viewModel.onQueryChanged("kotlin")
        assertThat(awaitItem().query).isEqualTo("kotlin")
    }
}

Rule 10: Permissions — request at point of use, handle denial gracefully

Permissions:
- Request via rememberLauncherForActivityResult — never startActivityForResult.
- Check permission before use: ContextCompat.checkSelfPermission.
- Handle denial: show rationale UI, offer settings redirect after permanent denial.
- Never crash or silently fail on permission denial.
- Dangerous permissions: request only when the user triggers the action.

Rule 11: API communication — Retrofit + OkHttp + kotlinx.serialization

Network:
- Retrofit for REST. OkHttp with logging interceptor (debug builds only).
- Serialization: kotlinx.serialization. No Gson, no Moshi.
- Response wrapper: Result<T> or sealed class — never nullable responses.
- Timeouts: explicit connect (10s), read (30s), write (30s) timeouts.
- Certificate pinning for production builds.

Rule 12: Build config — Gradle Kotlin DSL, version catalog

Build:
- Gradle Kotlin DSL (.kts) only — no Groovy.
- Version catalog (libs.versions.toml) for all dependencies.
- Build variants: debug (logging, mock data) / release (ProGuard, no logging).
- No hardcoded API keys or secrets in BuildConfig — use local.properties + secrets-gradle-plugin.
- Baseline profiles for startup performance.

Rule 13: The CLAUDE.md block for Android

## Android Standards

**Min SDK:** 26 | **Target SDK:** 35 | **Language:** Kotlin 2.x
**UI:** Jetpack Compose + Material3 only (no XML layouts)
**Architecture:** MVVM + Clean Architecture (presentation / domain / data)

### UI Layer
- Composables: stateless. State hoisted to ViewModel.
- UiState: immutable data class, exposed as StateFlow<UiState>
- Collect with collectAsStateWithLifecycle() — not collectAsState()
- One-time events: Channel<UiEvent> in ViewModel

### Data Layer
- Repository pattern: interface in domain, impl in data
- Room as source of truth. Network syncs to Room.
- Coroutines: suspend fun for one-shot, Flow for streams
- No callbacks, no RxJava, no AsyncTask

### DI
- Hilt exclusively. @HiltViewModel on all ViewModels.
- No manual DI, no Koin, no service locators.

### Testing
- ViewModel: JUnit 5 + MockK + Turbine
- No Robolectric. Pure JVM unit tests preferred.

### Build
- Gradle Kotlin DSL + version catalog (libs.versions.toml)
- No secrets in source code or BuildConfig

Why Android specifically needs this

Android’s API surface has been evolving continuously for 15 years. AI models have absorbed code from every era — from AsyncTask through RxJava to coroutines, from XML inflation through DataBinding to Compose.

Without explicit rules, the model defaults to the mean of that training distribution. That mean includes a lot of code that works on current Android but violates current best practices, creates maintenance debt, and will break when APIs are deprecated.

The rules above aren’t a style guide. They’re the line between code that a senior Android engineer would merge and code that gets sent back for a full rewrite.

The CLAUDE.md block at the end is what you add to your repo. The AI reads it at the start of every session, and every Composable, ViewModel, and repository it generates starts from those constraints rather than from the average of all Android code ever written.

Day 17 of 60: I Deployed My First Production AI API and It’s Live 24/7 (This Changes Everything)

Today everything became real.

Every system I’ve built over the past 16 days lived only on my local machine. The moment I closed my laptop it went offline. n8n Cloud couldn’t reach it without ngrok running. No client could access it. I couldn’t share it with anyone without being physically present.

Today I fixed that. My AI Agent API is live. 24/7. No laptop required.

## The Gap This Closes

There is a clear line between a developer project and a professional product.

A portfolio of systems that only run locally is a collection of demos. A deployed API that runs 24/7 from a cloud server is a product; something you can hand to a client, link to from your LinkedIn, or build a business on.

Today I crossed that line.

## What Got Deployed

A production-ready FastAPI server combining the capabilities from the last three projects into one unified AI agent API:

  • Document Q&A endpoint: ask questions against any document
  • Research endpoint: multi-agent research and report generation
  • Data analysis endpoint: CSV analysis with AI-generated insights

Deployed to Render’s free cloud hosting. Permanently accessible at a real URL. n8n Cloud workflows can now call it directly. Clients can access it. It’s in my portfolio.

## What I Learned

requirements.txt: the dependency file that tells cloud platforms what to install. Standard for every Python deployment.

Environment variables in production: storing API keys securely on Render so they are never in code or GitHub. This is non-negotiable in production.

Production start command: the uvicorn command Render uses to start the server. Different from how you run it locally.

Health check endpoint: the GET / endpoint that tells Render your service is alive.

Auto-deploy from GitHub: every time I push new code Render redeploys automatically. Continuous deployment from day one.

*🌐 Live API → https://ai-agent-api-w7uh.onrender.com
🔗 GitHub → https://github.com/mbuguacessy-glitch
*

43 more to go!.

python #fastapi #deployment #render #claudeapi #automation #learninpublic #buildinpublic #100DaysOfCode

🌱One Commit at a Time — My Open Source Journey

I want to paint my contribution graph into all green this year...

For the last two months, I’ve been showing up to open source every single day.

Not because I’m an expert.
Not because I already know everything.
And definitely not because every contribution is impressive.

I started this challenge on April 1st with one simple rule:

Contribute to open source every day for 1 year

That’s it.

Some days it’s a pull request.
Some days it’s documentation.
Some days it’s reviewing code, helping discussions, fixing typos, testing things, or simply learning a codebase deeply enough to leave a useful comment.

The goal isn’t perfection.

The goal is consistency.

Why I Started This Challenge

I’m a self-taught developer learning AI, engineering, and open source through real-world building and collaboration.

Over time, I noticed a pattern in how I approached open source and contributing. Even after using GitHub for around five years, I still had what I can only describe as “git anxiety” — hesitation around commits, branches, and contributing in unfamiliar repositories. Not because I didn’t understand the tools, but because I hadn’t built enough repetition for them to feel natural.

My contribution history also reflected that. For years, it was inconsistent — periods of activity followed by long gaps. That stop-and-go pattern made it harder to build confidence, momentum, or a real sense of progress.

At the same time, I had a clear long-term goal:
getting involved in a major open source project.

To get there, I realized I needed more than just technical knowledge. I needed comfort with the workflow itself — Git, collaboration patterns, reviews, and the daily rhythm of real projects.

I also noticed something more general about how people approach open source, myself included: there’s often a belief that we need to “be ready first” before contributing.

  • “I need to become better first.”
  • “I need to understand the entire codebase.”
  • “I need to build something significant before I start.”

But open source rarely works like that.

The people who grow in these spaces are usually the ones who stay present — consistently showing up, even in small ways.

So instead of waiting for a perfect moment or chasing large contributions immediately, I decided to shift my focus entirely toward building a habit:

show up daily, contribute publicly, and learn in the open.

I want to paint my contribution graph into all green this year...

I want to paint my contribution graph into all green this year…

What Counts as a Contribution?

A contribution doesn’t always mean shipping a major feature.

For this challenge, I define contribution broadly:

  • Writing or improving documentation
  • Reviewing pull requests
  • Opening issues
  • Participating in discussions
  • Reproducing bugs
  • Improving examples
  • Learning and documenting insights publicly
  • Working on my own repositories
  • Supporting community conversations

Even small actions help projects move forward.

And honestly, smaller contributions are often the best way to start.

My Daily System

I keep the process intentionally simple.

Usually it looks like this:

  1. Open GitHub
  2. Continue previous work or find one thing to improve
  3. Spend 30–60 minutes contributing
  4. Log the progress publicly

That’s all.

The simplicity matters because I want this challenge to survive busy days, low-energy days, and difficult weeks.

Consistency beats intensity.

What I’ve Been Doing So Far

Over the past two months, my days have included things like:

  • contributing to open source repositories
  • improving documentation and learning materials
  • exploring AI agent frameworks and tooling
  • building my own projects publicly
  • learning collaboration workflows through GitHub

A lot of the work is small.
Some of it is invisible.
But all of it compounds.

And that’s the point.

The Bigger Goal

This challenge isn’t only about GitHub streaks.

It’s about becoming the kind of engineer who can contribute meaningfully over the long term.

Right now, I’m still exploring different projects and communities.
Over time, I want to deepen my involvement, contribute more substantially, and eventually become a trusted contributor within projects I care about.

Not overnight.
One step at a time.

What I’ll Share Here

I’ll be documenting this journey regularly on dev.to throughout the year.

That includes:

  • Lessons from open source contributions
  • Things I learned while building AI and developer tools
  • mistakes and challenges
  • contribution workflows
  • beginner-friendly insights
  • thoughts about consistency, learning, and engineering growth

I want this series to be honest and practical — not just highlight reels.

If You’ve Been Hesitating to Start

You do not need to be an expert to contribute.

You only need to begin.

Open source can feel intimidating at first:
large repositories, unfamiliar workflows, experienced maintainers, complex codebases.

But contribution is a skill like anything else.
You improve by participating.

Even one thoughtful comment or documentation fix matters.

Join Me

If you’re also learning in public, contributing to open source, or trying to build consistency as a developer, feel free to join me.

Start small.
Show up consistently.
Document the journey.

One commit at a time. 🚀

# Day 5 of learning AI Engineering: built a small RAG app over a PDF

I built a small RAG (Retrieval Augmented Generation) project where a user can ask questions from a PDF, and the LLM answers from that PDF along with the page number to look at. The stack is LangChain, OpenAI embeddings, and Qdrant running in Docker.

A small note before we start: this exact same pipeline is what powers web-apps like an “AI Tutor in Educative”, an “AI web page builder”. The only thing that changes between those products and my PDF Q&A is the data source. That is the key idea to take away.

What RAG is, in one line

Take a document → break it into small chunks → turn each chunk into a vector (a list of numbers) → store those vectors in a database. Later, when the user asks a question, turn the question into a vector too, find the closest chunks, and feed them to an LLM as context.

INDEXING (run once)
───────────────────

   PDF file
      │
      ▼
   PyPDFLoader
      │  one Document per page (text + metadata)
      ▼
   RecursiveCharacterTextSplitter
      │  chunk_size = 1000, overlap = 200
      ▼
   ~200 small text chunks
      │
      ▼
   OpenAIEmbeddings (text-embedding-3-small)
      │  each chunk becomes a 1536-dim vector
      ▼
   Qdrant (running in Docker)
      │  vector + chunk text + page metadata stored


QUERY (every user question)
───────────────────────────

   "explain me about variables"
      │
      ▼
   OpenAIEmbeddings
      │  same model, same vector space
      ▼
   Query vector (1536-dim)
      │
      ▼
   Qdrant similarity_search (cosine, top k = 4)
      │  closest 4 chunks come back
      ▼
   Build a prompt with those chunks as context
      │
      ▼
   OpenAI Chat Completion (gpt-5-nano)
      │
      ▼
   Final answer + page citations

The stack and why I picked it

  • LangChain is the glue. It gives me one interface to talk to many providers. If I want to swap OpenAI for Cohere, or Qdrant for Pinecone, I change one line. It also has loaders for PDFs, websites, Notion, Google Docs, CSVs, image files, and a lot more. Plus tools for chains, agents, memory, prompts, and output parsing.
  • Qdrant is the vector database. I ran it locally in Docker so I don’t have to pay for a managed service while learning.
  • OpenAI text-embedding-3-small is the embedding model. More on why this one and not the large one further down.
  • uv instead of pip. Faster, lockfile-based, and the modern Python experience.

Some things worth pointing out:

  • The embedding model is just a client — no API call happens when you create it. The actual call to OpenAI happens inside from_documents.
  • from_documents is the convenience method. It connects to Qdrant, creates the collection, embeds every chunk, and inserts it. One call, three jobs.

Now the real question — how big a PDF can I actually ingest?

This is the part I couldn’t find a straight answer to in any tutorial. The pipeline above worked beautifully for my 123 KB, 71-page PDF. But what about a 100 MB book? A 1 GB legal document dump?

The answer has many layers. There is no single PDF size limit.

Layer Hard limit? What actually matters
PyPDFLoader No RAM. The whole PDF gets loaded into memory before chunking.
RecursiveCharacterTextSplitter No None. It just splits whatever you give it.
OpenAI embeddings API Yes Tokens per input + rate limits per minute + your wallet.
Qdrant Practically no Designed for millions of vectors.
Your laptop Yes RAM, disk, and patience.

The actual bottleneck is almost always OpenAI, not Python or Qdrant.

What OpenAI actually limits

There are three things to watch on the embeddings API.

1. Max tokens per single input — 8,191

Each chunk you send to be embedded can be at most 8,191 tokens, which is roughly 32,000 characters. My chunk_size=1000 is way below that, so this is never hit in normal RAG. Official source: https://platform.openai.com/docs/guides/embeddings

2. Rate limits — RPM (requests per minute) and TPM (tokens per minute)

These depend on your account tier. Tier 1, where most new accounts start, looks like this:

Tier RPM TPM Batch queue limit
Free 100 40,000 —
Tier 1 3,000 1,000,000 3,000,000
Tier 2 5,000 1,000,000 20,000,000
Tier 3 5,000 5,000,000 100,000,000
Tier 4 10,000 5,000,000 500,000,000
Tier 5 10,000 10,000,000 4,000,000,000

Your tier moves up automatically as you spend more on the platform.

When you hit the TPM, LangChain backs off and retries automatically. So your script doesn’t crash — it just takes longer.

3. The cost — $0.02 per 1M tokens for text-embedding-3-small

This is the most underrated number. A 100 MB text PDF is roughly 20–50 million tokens. That works out to about $0.40 to $1.00 in embedding cost. Real-world cheap. Pricing page: https://platform.openai.com/docs/pricing