Imagine opening your laptop and writing code that follows the laws of Quantum Physics. Sounds like science fiction, right?
That's exactly what I thought the first time I heard about quantum computing. I assumed quantum computers were machines hidden inside secret laboratories. I imagined researchers in white coats working with equipment worth millions of dollars.
Then I discovered something surprising: you can write and run your first quantum program using Python on a regular laptop.
No quantum computer required. No physics degree required. No advanced mathematics required.
Just Python.
In this tutorial, you'll learn how to build your first quantum circuit using Python and Qiskit.
By the end, you'll understand what a quantum circuit is, how qubits work, and how to create one of the most famous experiments in quantum computing called a Bell State.
Let's get started.
Most software developers work on regular computers, just like yours - a laptop, smartphone, or gaming console.
Every one of these devices processes information using bits. A bit can only have one value at a time: 0 or 1 Nothing in between.
Quantum computers use something different. They use qubits. A qubit can behave like a 0 and a 1 at the same time until it's measured.
Don't worry if that sounds strange. It sounds strange to everyone the first time.
Think about a coin. When a coin is lying flat on a table, it's either heads or tails. That's exactly how a regular computer bit works. But now, imagine spinning that coin. While it's spinning, it's a blur of both heads and tails at the same time. That's exactly how a quantum bit works
This isn't a perfect explanation. But it's a useful one for beginners.
This ability allows quantum computers to solve certain types of problems differently from regular computers.
You might be thinking: "I'm a Python developer. Why should I learn quantum computing?"
Good question.
The truth is that quantum computing is still in its early stages, but so was artificial intelligence a few years ago. Developers who learn early often gain an advantage.
Python has become one of the most popular languages for quantum programming because it is simple and beginner friendly. Many major quantum platforms provide Python libraries. These include:
Among these options, Qiskit is one of the easiest places to start. That's what you'll use in this tutorial.
If you already know variables, functions, and basic Python syntax, you're ready to begin.
If you've built web applications before, you're probably familiar with workflows.
For example:
User clicks button
↓
Data is validated
↓
Request is sent
↓
Response is returned
A quantum circuit works in a similar way. Instead of processing user input, it processes qubits.
A quantum circuit is simply a sequence of instructions applied to qubits.
Here's a simplified view:
Create qubits
↓
Apply quantum gates
↓
Measure results
↓
Display output
At its core, a quantum circuit simply involves initializing qubits, performing operations, and measuring the results.
If you've written Python before, you've probably changed values many times.
For example:
light = False
light = not light
print(light)
Output:
True
The not operator changes the value. It takes False and turns it into True.
Quantum computers also need ways to change values. Instead of using operators like not, they use something called quantum gates.
Think of quantum gates as special instructions that tell a qubit what to do.
Just like Python has:
not
+
-
*
Quantum computing has:
X Gate
H Gate
CX Gate
Let's understand them one at a time.
Imagine the light switch in your room.
When the switch is OFF: OFF. Press the switch. Now it becomes: ON. Press it again. It becomes OFF.
The switch keeps flipping between the two states, and that's exactly what the X Gate does.
0 → 1
1 → 0
qc.x(0)
This means: Apply an X Gate to qubit 0.
If qubit 0 was behaving like a 0, it now behaves like a 1.
If it was behaving like a 1, it becomes a 0.
Think of the X Gate as the quantum version of a light switch or a Python not operator.
Now things get interesting. Imagine I place a coin on a table. It can only be Heads or Tails, right?
That's how a normal computer works. A bit is either 0 or 1
Now imagine I spin that coin. While it's spinning, can you confidently say it's heads at any given moment?
No.
Can you confidently say it's tails?
No.
It hasn't landed yet. It's in a special state where both outcomes are possible.
That's the easiest way to think about what the H Gate (or Hadamard Gate) does.
qc.h(0)
This tells Qiskit to put qubit 0 into a superposition.
In beginner language, the qubit is no longer locked to just 0 or just 1. It now has a chance of becoming either when we measure it. Think of it like a spinning coin waiting to land.
0
0 and 1 are both possible
This idea is one of the reasons quantum computers are so powerful.
Instead of exploring only one possibility at a time, they can work with multiple possibilities.
The CX Gate, also called the CNOT (Controlled NOT Gate), is different from the X and H gates because it works with two qubits instead of one.
To understand how it works, let's use a simple real-life example.
Imagine you and your friend are playing a game. Before the game starts, you both agree on one rule.
If you raise your hand, your friend must immediately switch what they're doing. If they were standing, they should sit. If they were sitting, they should stand.
But if you keep your hand down, your friend does nothing and stays exactly as they are.
Notice something important: your friend's action depends entirely on what you do. They don't decide on their own.
That's very similar to how the CX Gate works.
Here's how we use it in Qiskit:
qc.cx(0, 1)
This line tells Qiskit: "Use qubit 0 to control what happens to qubit 1."
In this case:
Qubit 0 → Control qubit
Qubit 1 → Target qubit
The control qubit makes the decision, and the target qubit responds.
If the control qubit is 0, nothing happens. The target qubit stays exactly the same.
If the control qubit is 1, the target qubit flips: 0 becomes 1.
Think of the control qubit as a manager giving instructions to an employee. The employee doesn't act randomly. They only change what they're doing when the manager gives the signal.
By itself, the CX Gate is already useful.
But when we combine it with the Hadamard gate, something amazing happens. The two qubits become connected in a special way called entanglement. You'll learn about that later in this tutorial. Now, it's time to practice what you've learned using Python.
Here comes the fun part. Let's prepare your machine. Before you continue, make sure Python is installed on your local computer. For this tutorial, use Python version 3.12.8 or 3.13.8. Those versions work well with all the dependencies you'll be installing.
3.12.8
Create a folder called: quantum-python and then open it in VS Code.
In your terminal (here I'm using Git Bash), run:
python -m venv .venv
Then activate it. On Windows using Git Bash, run:
source .venv/Scripts/activate
And on MacOS/Linux:
source .venv/bin/activate
Run:
pip install qiskit qiskit-aer matplotlib
This installs:
Qiskit
Quantum simulator
Chart visualization tools
Create a file called:
test.py
Add:
import qiskit
print(qiskit.__version__)
Run:
python test.py
If you see a version number, you're ready.
Congratulations! You've officially entered the world of quantum programming.
Create a new file called bell_state.py. This file will contain your first quantum program.
Now you need to import Qiskit. Add:
from qiskit import QuantumCircuit
qc = QuantumCircuit(2, 2)
This imports the QuantumCircuit class.
What does this mean? QuantumCircuit(2, 2) creates 2 qubits and 2 classical bits.
The classical bits will store the final results after measurement.
Let's print the circuit.
print(qc)
Output:
q_0:
q_1:
c:
Right now, nothing is happening. The circuit is empty. You're about to change that.
Let's add our first quantum gate: qc.h(0).
This applies a Hadamard Gate to qubit 0.
Your code becomes:
from qiskit import QuantumCircuit
qc = QuantumCircuit(2, 2)
qc.h(0)
print(qc)
Output:
───
q_0: ┤ H ├
───
q_1: ─────
c: 2/═════
The H gate places qubit 0 into superposition. This is where quantum behavior begins.
You have officially created your first quantum state.
So far, we've only worked with a single qubit. Let's do something much more interesting.
You can make two qubits work together. This phenomenon is called entanglement.
If you've spent time on tech Twitter or watched science videos on YouTube, you've probably heard people call entanglement "spooky action at a distance."
Don't worry about the fancy name, just focus on the code.
Add this line beneath your Hadamard gate: qc.cx(0, 1).
Your program should now look like this:
from qiskit import QuantumCircuit
qc = QuantumCircuit(2, 2)
qc.h(0)
qc.cx(0, 1)
print(qc)
Output:
───
q_0: ┤ H ├─■──
─── ─┴─
q_1: ────┤ X ├
───
c: 2/══════════
But what exactly happened?
The first qubit entered superposition when we applied the H gate. The CNOT gate then linked the second qubit to the first. Now the two qubits behave as a connected system, not two separate pieces of information. Just one shared quantum state.
Think about two perfectly synchronized dice. Every time you roll them, they somehow always show the same number.
Sounds impossible, right? That's because it is impossible in normal classical computing.
But quantum mechanics plays by different rules.
Right now our qubits exist in a quantum state, but computers can't display quantum states directly.
We need to measure them. Measurement converts quantum information into classical information.
Add the following line: qc.measure([0, 1], [0, 1]).
Your code now becomes:
from qiskit import QuantumCircuit
qc = QuantumCircuit(2, 2)
qc.h(0)
qc.cx(0, 1)
qc.measure([0, 1], [0, 1])
print(qc)
What does this line do?
It means:
Measure qubit 0
Store result in classical bit 0
and
Measure qubit 1
Store result in classical bit 1
At this point our circuit is complete. Now we need to execute it.
Here's the cool part. You don't need a quantum computer. Your laptop can simulate one.
Create a new section beneath your circuit.
from qiskit_aer import AerSimulator
simulator = AerSimulator()
result = simulator.run(
qc,
shots=1024
).result()
counts = result.get_counts()
print(counts)
Let's break it down.
AerSimulator is Qiskit's local quantum simulator.
Instead of sending your program to a real quantum machine, it runs everything on your computer.
This is perfect for learning and experimentation, and it's completely free.
Notice this line: shots=1024.
A shot is a single execution of the quantum circuit. Quantum outcomes are probabilistic, which means that one execution isn't enough.
Running 1,024 shots lets us see the overall pattern.
Think of it like flipping a coin. One flip tells you nothing but a thousand flips reveal the probabilities.
At this point your file should look like this:
from qiskit import QuantumCircuit
from qiskit_aer import AerSimulator
qc = QuantumCircuit(2, 2)
qc.h(0)
qc.cx(0, 1)
qc.measure([0, 1], [0, 1])
simulator = AerSimulator()
result = simulator.run(
qc,
shots=1024
).result()
counts = result.get_counts()
print(counts)
Save the file.
Run: python bell_state.py.
You should see something similar to:
{
'00': 504,
'11': 520
}
Your numbers will be slightly different, which is normal. The important thing is that you see: 00and 11.
You should never see: 01 or 10
And that's the clue that tells us entanglement is working.
If you're using Windows, you might run into this error when importing AerSimulator:
ImportError: DLL load failed while importing controller_wrappers:
The specified module could not be found.
This usually isn't a problem with your code. It happens because Microsoft Visual C++ Redistributable 2015–2022 (x64) isn't installed on your system.
To fix it:
Download and install the Microsoft Visual C++ Redistributable 2015–2022 (x64) from the official Microsoft website.
Restart your computer.
Reopen your terminal and run your program again.
Once the runtime is installed, AerSimulator should import successfully, and you can continue with the rest of the tutorial.
If your code doesn't work immediately, don't panic. Everyone hits errors.
Common issues include:
ModuleNotFoundError
Solution: pip install qiskit.
Make sure your virtual environment is activated before running the script.
Install: pip install qiskit-aer.
Remember that Python cares about spacing. Check your indentation carefully.
Developers love visual feedback. A chart makes quantum behavior easier to understand. You can create one.
Add:
from qiskit.visualization import plot_histogram
import matplotlib.pyplot as plt
plot_histogram(counts)
plt.show()
Your bell_state.py file will now look like this:
# IMPORT DEPENDENCIES
from qiskit import QuantumCircuit
from qiskit_aer import AerSimulator
from qiskit.visualization import plot_histogram
import matplotlib.pyplot as plt
# Create a Quantum Circuit with 2 qubits and 2 classical bits
qc = QuantumCircuit(2, 2)
# Create a Bell state (entanglement) using a Hadamard and a CNOT gate
qc.h(0)
qc.cx(0, 1)
# Measure all qubits into their corresponding classical bits
qc.measure([0, 1], [0, 1])
# Initialize the Aer simulator and execute the circuit for 1024 shots
simulator = AerSimulator()
result = simulator.run(
qc,
shots=1024
).result()
# Gather the resulting measurement counts
counts = result.get_counts()
# Print raw text counts and plot the histogram data
print(counts)
plot_histogram(counts)
plt.show()
Run your program again, a histogram should appear.
It will look something like this:
For the complete project folder, you can get it from Github.
Let's pause for a second because something incredible just happened.
You created entanglement using Python on your laptop without owning a quantum computer.
The first qubit entered superposition.
The second qubit became linked to it.
When measurement happened, both became 0 or both become 1. The outcome was random, but they always agreed. That's the key observation.
The Bell State is one of the most famous examples in quantum computing. It's often the first experiment beginners learn.
Why? Because it demonstrates two important quantum ideas:
Superposition
Entanglement
Without Bell States, many quantum algorithms wouldn't exist because:
Quantum communication systems depend on them.
Quantum cryptography depends on them.
Future quantum networks depend on them.
The Bell State is basically the "Hello World" of quantum computing. Every quantum developer encounters it sooner or later.
At first glance, this experiment seems small: Two qubits, Two gates, and a few lines of Python. Yet, the idea behind it is huge.
Bell States do much more than demonstrate entanglement. Researchers use them as benchmark experiments to verify that quantum hardware can reliably create and measure entangled qubits.
For example, Bell State circuits are commonly executed on superconducting quantum processors to evaluate how accurately the hardware prepares entangled states before running more complex quantum algorithms.
Bell States also play an important role in quantum communication and serve as building blocks for larger quantum algorithms.
Think of them like functions in programming. A single function may seem small but complex applications are built from thousands of them.
The same idea applies here. Large quantum systems are built from smaller quantum operations.
A common question beginners ask is: "When will I actually use this?"
Fair question.
Here are some real examples.
While traditional encryption relies on mathematical difficulty, quantum cryptography relies on the laws of physics, ensuring that any attempt to intercept data changes the quantum state and makes eavesdropping immediately detectable.
Researchers developing quantum internet technologies are heavily leveraging quantum entanglement to connect quantum devices across large distances.
Quantum computers may eventually simulate molecules more accurately than classical computers. This could help researchers discover new medicines, improve materials, and understand chemical reactions.
Large financial institutions are exploring quantum algorithms for:
Portfolio optimization
Risk analysis
Market simulation
The field is still developing, but the potential is enormous.
The best way you can learn quantum computing is exactly how developers learn programming:
Break things.
Experiment.
Change the code.
Observe the results.
Let's try a few simple experiments.
Delete: qc.h(0) and run the circuit again. What changes?
Observe the output. Why do you think that happened?
Change: shots=1024 to shots=100000.
Run the simulation again. Notice how the results become more balanced. This is probability in action.
Insert: qc.x(1) before the CNOT gate.
Run the circuit.
While studying the new output distribution, try predicting the results before running the code.
Change: QuantumCircuit(2, 2) to QuantumCircuit(3, 3).
Can you create a larger entangled system? Experiment and see.
You've now built your first quantum circuit. That's a big milestone.
Here are some great next steps you can explore through IBM Quantum Platform:
X Gate
Y Gate
Z Gate
S Gate
T Gate
Try:
GHZ States
Quantum Teleportation
Deutsch Algorithm
IBM allows developers to run circuits on actual quantum computers. This is one of the coolest experiences in modern programming.
Once you're comfortable with circuits, explore:
Grover's Algorithm
Shor's Algorithm
Quantum Fourier Transform
A few years ago, quantum computing felt impossible to approach. It seemed reserved for physicists and researchers.
Today, that's no longer true. If you know Python, you already have a pathway into quantum development.
In this tutorial, you learned:
What quantum computing is
How qubits differ from bits
What quantum gates do
How to install Qiskit
How to create a Bell State
How to simulate a quantum circuit
How to visualize results
Why entanglement matters
Most importantly, you wrote your first quantum program. That's how every quantum developer starts.
One circuit. One experiment. One curiosity-driven question at a time.
Now open your editor and modify the code. Break things. Try new gates. And start exploring the quantum world for yourself.
Every November, .NET Conf brings a new major release of .NET. If you have been building on the platform since the early 2020s, you have watched the ecosystem move from “.NET Core” to a single, unified .NET — and pick up minimal APIs, Native AOT, Blazor full stack, Aspire, and a steady stream of C# language improvements along the way.
This post is a guided tour of what landed in each major version from .NET 5 through .NET 10. It is not exhaustive — Microsoft ships hundreds of changes per release — but it should help you remember which version introduced what, and where to dig deeper.
| Version | Released | Support type | C# version | Status (June 2026) |
|---|---|---|---|---|
| .NET 5 | Nov 2020 | STS (18 months) | C# 9 | End of support |
| .NET 6 | Nov 2021 | LTS (3 years) | C# 10 | End of support |
| .NET 7 | Nov 2022 | STS (18 months) | C# 11 | End of support |
| .NET 8 | Nov 2023 | LTS (3 years) | C# 12 | Supported |
| .NET 9 | Nov 2024 | STS (2 years) | C# 13 | Supported |
| .NET 10 | Nov 2025 | LTS (3 years) | C# 14 | Current LTS |
| .NET 11 | Nov 2026 | Preview | C# 15 | Preview Until Nov 2026 then STS |
For the full release schedule and patch versions, see the official .NET releases documentation.
.NET 5 was the first release after the “Core” branding was dropped. Version 4.x was skipped deliberately so nobody confused it with .NET Framework 4.x. The message was clear: this is the main implementation of .NET going forward, not a side project.
C# 9 shipped alongside .NET 5 and brought several features that are now everyday tools:
with expressions for non-destructive mutation.>, <, and, or, not) and improved switch expressions.[JsonIgnore] on properties, and better options for ignoring cycles..NET 5 reached end of support in May 2022. If you still have projects on it, plan an upgrade — there are no security patches.
.NET 6 delivered on the unification promise: one SDK, one base class library, and one runtime across mobile, desktop, IoT, and cloud. It was an LTS release and became the baseline many teams standardised on.
dotnet watch — edit code and see changes without a full restart.dotnet workload instead of bloating the default SDK.async Main in new projects.global using directivesAsyncMethodBuilder attribute for custom async method buildersDateTime.IAsyncEnumerable serialisation.System.Diagnostics.Metrics..NET Multi-platform App UI arrived in preview with .NET 6 — one codebase for Android, iOS, macOS, and Windows native apps. GA followed in 2022.
.NET 6 LTS ended in November 2024.
.NET 7 was a standard-term support release focused heavily on performance, cloud-native patterns, and polishing the developer experience. I wrote about upgrading to .NET 7 at the time — mostly smooth, with a few surprises around AutoMapper and EF Core triggers.
required members — enforce initialisation at compile time.if (list is [var first, .., var last]).u8 suffix for byte-oriented APIs..NET 7 reached end of support in May 2024.
.NET 8 is the current-generation LTS baseline for many production workloads (supported until November 2026). It is the release where cloud-native tooling, Native AOT for web apps, and modern C# really clicked together.
[1, 2, 3] syntax for arrays, spans, and lists.using Point = (int X, int Y);.NET Aspire launched in preview with .NET 8 — an opinionated stack for observable, distributed applications. AppHost orchestrates dependencies; ServiceDefaults wire up OpenTelemetry, health checks, and resilience. I have been using it for local microservice development and it removes a lot of port-and-connection-string drudgery. In 2025 .Net Aspire was rebranded to Aspire and version 13 was released (don’t ask why 13, but I believe it was so it was no longer tied to .NET releases)
HierarchyId..NET 9 continued the annual cadence with performance, AI integration, and polish across the stack. I covered several highlights in my .NET 9 post when it shipped.
params collections — params works with Span<T>, ReadOnlySpan<T>, and collection types beyond arrays.lock on System.Object — the lock statement can use more types safely.\e for the ESC character.Microsoft.Extensions.AI packages unify access to language models and embeddings from different providers.<NuGetAuditMode>direct</NuGetAuditMode>).MapStaticAssets() — fingerprinted, cache-friendly static files replace UseStaticFiles() for Blazor and modern web apps..NET 9 is standard-term support until November 2026.
.NET 10 is the current LTS release (supported until November 2028). If you are choosing a version for a new project in 2026, this is the long-term bet.
field keyword when you outgrow auto-properties.nameof for unbound generics — e.g. nameof(List<>).Span<T> conversions — less ceremony at API boundaries.ref/out/in in lambdas without explicit parameter types.?.=) and user-defined compound assignment operators.PipeReader support, duplicate property handling.WebSocketStream — simpler WebSocket usage; TLS 1.3 on macOS clients.dotnet test integrates Microsoft.Testing.Platform.dotnet tool exec for one-shot tool runs.One breaking change caught me when upgrading a Blazor WebAssembly app: HttpClient response streaming is enabled by default, which broke synchronous Stream.Read calls in generated API clients. I wrote about the fix in Blazor and .NET 10
— either opt out with <WasmEnableStreamingResponse>false</WasmEnableStreamingResponse> or move to async reads.
| Scenario | Recommendation |
|---|---|
| New production app, minimise upgrade churn | .NET 10 LTS |
| Existing app on .NET 8 LTS with no pressing need | Stay on 8 until you are ready; both 8 and 10 are supported |
| Experimenting with the latest features | .NET 9 or .NET 10 — check STS/LTS dates |
| Legacy maintenance | Upgrade anything still on 5, 6, or 7 — they are all end-of-life |
Microsoft’s guidance has shifted over the years: LTS releases (6, 8, 10) alternate with STS releases (5, 7, 9) on an annual November cadence. STS now lasts two years; LTS lasts three. Remember there is no difference in quality between a LTS and STS release, it is only the support window that is different.
If you have been on the platform since .NET 5, you have lived through the best decade of .NET since the original framework shipped.
A Bloom filter gives you something that feels like magic: it can tell you whether an item is in a set of billions, using only a few kilobytes of memory. And it answers in the same tiny amount of time no matter how much you have stored.
That sounds impossible. A normal set has to remember every item, so its memory grows with the data. But a Bloom filter remembers almost nothing about the items themselves, yet it still answers membership questions. The catch is that it's allowed to be wrong in one specific, controllable direction.
It's not magic, and the moment you build one yourself, the trick becomes clear and you should understand exactly what it can and can't promise.
In this tutorial, we'll build a working Bloom filter from scratch in Python, using nothing but a list of bits and a couple of hash functions. By the end, you'll understand bit arrays, why we use several hashes, what a false positive is, the one guarantee a Bloom filter never breaks, and how to size one for a target error rate.
A Bloom filter is a probabilistic data structure. Its whole job is to answer one question, "is this item in the set?", and it gives one of only two answers:
Definitely not in the set. This answer is always correct.
Possibly in the set. This answer is usually correct, but it's occasionally wrong.
The surprising part is that it answers without storing the items at all. A normal set, like Python's set or a hash table, keeps every item it has seen, so its memory grows with both the number of items and the size of each one.
A Bloom filter keeps only a fixed row of bits. Its size is decided up front and never changes, whether you store short words or long URLs or whole files.
So a Bloom filter isn't really a container. It's closer to a fingerprint of a set. You can't ask it to list what's inside, or to hand an item back. You can only ask "have you probably seen this?", and you can trust its "no" completely.
A quick way to picture it: instead of keeping a guest list of names, you keep a wall of light switches. When a guest arrives, you flip a few switches chosen from their name. To check whether someone came, you look at their switches. If any one of them is off, they definitely never arrived. If all of them are on, they probably did, though someone else's name might have flipped those same switches.
That picture also explains why you would reach for one instead of a plain set. For a million URLs averaging fifty bytes each, a real set costs tens of megabytes and grows with the length of the URLs. A Bloom filter for the same million items at a one percent error rate costs about 1.2 megabytes, fixed, no matter how long the URLs are.
When the set is huge, has to live in memory on every machine, or holds large items, that saving is the difference between practical and impossible. The price is the rare false positive, and the usual pattern makes that cheap: a "no" skips an expensive lookup, and a "yes" just triggers the slower exact check you would have run anyway.
The rule of thumb: if you need exact answers, deletion, or the ability to list what is stored, use a real set. If you need a tiny, fast gate that sits in front of an expensive operation and reliably tells you when you can skip it, use a Bloom filter.
The structure is named after Burton Howard Bloom, who described it in a 1970 paper, "Space/Time Trade-offs in Hash Coding with Allowable Errors", in Communications of the ACM.
His motivating example was wonderfully ordinary. A program that hyphenated and spell-checked text needed to look words up in a dictionary, and storing the whole dictionary in the tiny memories of 1970 was too expensive. Bloom's idea was to accept a small, controlled rate of mistakes in exchange for a large saving in space. That single trade, allow a little error and save a lot of memory, is why the structure still turns up in so many large systems more than fifty years later.
You've very likely used software backed by a Bloom filter today. They're important in:
Databases and storage engines: Cassandra, HBase, Bigtable, and many log-structured (LSM-tree) stores keep a Bloom filter for each on-disk file. Before a slow disk read, the engine asks the filter "could this key be in this file?" A "no" lets it skip the file entirely, which avoids a huge number of reads.
Safe browsing: Early versions of Google Chrome checked each URL against a local Bloom filter of known-dangerous sites. A "no" meant safe, with no network call. A "yes" was rare and triggered a real check against the full list.
Caches and CDNs: A common trick is to cache an item only after it has been requested at least twice. A Bloom filter cheaply remembers "have I seen this once before?", which filters out the flood of one-time requests.
Recommendations: Medium has described using a Bloom filter to avoid recommending articles you've already read.
Networking and crypto: Routers use them to spot duplicate packets, and early Bitcoin light clients used them to request relevant transactions without revealing exactly which addresses they cared about.
The shape is always the same. A Bloom filter stands in front of something expensive (a disk read, a network request, a database query) and turns most of those expensive checks into a couple of fast array reads. Now let's build one and see exactly how.
A Bloom filter is built on two pieces:
A bit array: a long row of bits, all starting at 0.
A handful of hash functions that each turn an item into a position in that array.
To add an item, you run it through each hash function, get several positions, and set the bit at each of those positions to 1.
To check an item, you run it through the same hash functions and look at those same positions. If every one of them is 1, the item is "probably present". If even one is 0, the item is "definitely absent".
That second answer is the important one. If a bit is still 0, you know for certain you never added anything that would have set it. The filter never misses something it has actually seen.
Here's the whole structure in Python:
import hashlib
class BloomFilter:
def __init__(self, size, num_hashes):
self.size = size # number of bits in the array (m)
self.num_hashes = num_hashes # number of hash functions (k)
self.bits = [0] * size # every bit starts at 0
We need num_hashes different positions for each item, and they need to be spread out. A common, clean trick is double hashing: compute two independent hashes once, then combine them to produce as many positions as you need.
def _positions(self, item):
data = item.encode("utf-8")
h1 = int.from_bytes(hashlib.sha256(data).digest()[:8], "big")
h2 = int.from_bytes(hashlib.md5(data).digest()[:8], "big")
for i in range(self.num_hashes):
yield (h1 + i * h2) % self.size
Three things are happening:
sha256 and h2 from md5 give us two big numbers that are stable for the same string and look random across different strings.
h1 + i * h2 mixes them into a different value for each i, so the positions scatter instead of clumping together.
% self.size folds each value into a valid index, from 0 to size - 1.
Run this for one item and you get num_hashes positions. Those positions are the item's fingerprint inside the filter.
Adding sets the bit at every position. Checking asks whether they're all set.
def add(self, item):
for idx in self._positions(item):
self.bits[idx] = 1
def __contains__(self, item):
return all(self.bits[idx] for idx in self._positions(item))
Defining __contains__ lets us use Python's natural in syntax. Let's try it:
bf = BloomFilter(size=1000, num_hashes=4)
bf.add("alice")
bf.add("bob")
print("alice" in bf) # True
print("bob" in bf) # True
print("carol" in bf) # almost always False
"carol" was never added, so at least one of its four bits is almost certainly still 0, and the filter reports absence. That's the common case. But notice the words "almost certainly". That hedge is the whole story of the next section.
Bits are shared. With enough items added, the four bits that happen to encode "carol" might all have been set to 1 by other items, even though "carol" itself was never added. When that happens, the filter says "probably present" for something that's absent. That's a false positive.
People new to Bloom filters sometimes think this is a bug. It's not. It's the price you pay for using so little memory, and it's tunable. You can watch it happen by cramming many items into a small filter:
bf = BloomFilter(size=200, num_hashes=4)
for i in range(100):
bf.add(f"user-{i}")
# None of these were added, but some will sneak through as "present":
false_hits = sum(f"ghost-{i}" in bf for i in range(1000))
print(false_hits) # a non-zero number: the false positive rate in action
The filter is never wrong in the other direction, though. Every user-i you added still returns True, because adding an item sets all of its bits, and those bits never get cleared. This is the one promise a Bloom filter always keeps:
A "no" is always correct. No false negatives, ever.
A "yes" might be wrong. False positives are possible.
That asymmetry is exactly what makes Bloom filters useful. A web browser can keep a Bloom filter of known-malicious URLs and check every link instantly. A "no" means the link is safe and needs no further work. A "yes" is rare and just triggers a slower, exact check against the real list. The filter turns most lookups into a couple of array reads.
The false positive rate depends on three numbers: the bit array size m, the number of items you expect to add n, and the number of hash functions k. The approximate false positive rate is:
p = (1 - e^(-k*n/m)) ** k
You don't have to guess these. Given the number of items n and a target false positive rate p you can pick the best m and k directly:
import math
def optimal_params(n, p):
m = math.ceil(-n * math.log(p) / (math.log(2) ** 2)) # bits needed
k = max(1, round((m / n) * math.log(2))) # hashes to use
return m, k
print(optimal_params(1_000_000, 0.01)) # about (9_585_059, 7)
Read that result carefully. To track one million items with a one percent error rate, you need roughly 9.6 million bits, which is about 1.2 megabytes, and 7 hash functions.
A real set of one million strings would cost far more, and most of that cost grows with the length of the strings. The Bloom filter doesn't care how long the items are, only how many there are.
There's one more honest limitation. You can't remove an item by clearing its bits, because those bits are shared. Clearing the bits for "alice" might also clear a bit that "bob" depends on, and now "bob" would wrongly report as absent, breaking the no-false-negatives promise.
If you need deletion, the standard fix is a counting Bloom filter, where each slot is a small counter instead of a single bit. Add increments the counters, remove decrements them, and a slot counts as "set" while its counter is above zero. It costs more memory, which is the usual trade.
Here's what we built and what it costs:
| Operation | Cost |
|---|---|
add |
O(k) |
in (check) |
O(k) |
| space | about m bits for n items, independent of item size |
The takeaways:
A Bloom filter is a bit array plus a few hash functions. Adding sets k bits, checking asks whether those k bits are all set.
A "no" is always correct. A "yes" can be a false positive, and the rate is something you tune with m and k.
It's tiny and fast because it stores fingerprints, not the items, so it forgets what the items actually were.
It can't delete without a counting variant, because bits are shared.
The next time a system tells you "this is definitely not in the cache, skip the lookup" or "this might be a known item, let me double-check", you'll know exactly what's underneath: a row of bits, a few hashes, and one carefully chosen direction in which it's allowed to be wrong.
If you enjoy learning data structures by building them rather than memorizing them, that's the idea behind a learn-by-doing platform I built called IWTLP, where this Bloom filter is one of the build-it-yourself exercises in the data engineering track.
In early 2023, I was interning at a US-based company, long before agentic AI became part of everyday development.
We had tools like ChatGPT, Gemini, and Copilot, but they were mostly chat interfaces: you pasted code, got a response, and moved on.
During that time, my manager, who worked in AI/ML, told me that a day would come when developers would collaborate with AI agents and that learning how to write effective prompts would become a valuable skill.
I took that advice seriously. I spent countless nights experimenting with prompts, refining instructions, and learning how to communicate with AI systems effectively.
Today, while I still write code by hand and believe strongly in fundamentals, those early lessons have paid off. In an era where AI is embedded into the development workflow, I've been able to leverage it to significantly amplify my productivity as a software engineer.
You've probably seen all the excitement around AI coding assistants. But if you've tried using one on a real Flutter project, whether it's a fintech app, an e-commerce platform, or any application with a well-structured architecture, you've likely experienced the frustration, too.
The assistant generates a widget. You paste it in. It doesn't fit your architecture. It ignores your naming conventions. It recreates functionality that already exists somewhere else in your codebase. Before long, you've spent twenty minutes fixing code that was supposed to save you time.
The problem isn't the AI. The problem is that most developers still use AI as an advanced autocomplete tool when it can function as something much more powerful: a second engineer that understands your codebase, follows your conventions, and tackles parallel tasks while you focus on solving the hard problems.
In this article, I'll show you what has actually worked for me. We'll cover how to structure your Flutter projects so Claude Code can navigate them effectively and how to use skills, loops, and subagents to automate repetitive development tasks and dramatically increase your productivity.
Before following along, you should be comfortable with the basics of Flutter development; building widgets, managing state, and running the app from the terminal. You don't need to be an expert.
On the tooling side, you'll need:
Flutter SDK (3.x or later): the framework we're building with. Install it from flutter.dev.
Claude Code: Anthropic's agentic coding tool that runs in your terminal alongside your editor. Install it with npm install -g @anthropic-ai/claude-code, then run claude in your project directory to start a session. You'll need an Anthropic account and API key.
A code editor: VS Code or Android Studio both work well. Claude Code operates in the terminal and reads/writes files directly, so it works alongside whatever editor you use.
Git: version control is assumed throughout. Claude Code integrates with Git for commits, diffs, and branch awareness.
Here's a quick overview of the Claude Code concepts we'll use throughout the article:
CLAUDE.md: a markdown file at your project root that Claude reads at the start of every session. Think of it as a briefing document: your architecture, your conventions, your commands.
Skills: reusable instruction packs stored in .claude/skills/. You define them once, and Claude invokes them automatically when the task matches, or you call them manually with /skillname.
Subagents: isolated Claude instances that handle a focused task in their own context window, then return only a summary. Great for parallel work without polluting your main session.
Hooks: shell commands or scripts that fire on lifecycle events (before a tool runs, after a turn completes, and so on). They bypass Claude's judgment entirely — useful for enforcing rules deterministically.
/loop: a built-in skill that reruns a task repeatedly until a condition you define is met.
None of these require special configuration to unlock. They’re all available once you have Claude Code installed.
Before you write a single skill or configure a single hook, your folder structure needs to make sense to an AI reading it cold.
Claude Code reads your files to understand your project. If your code is scattered across a layer-first structure (lib/models/, lib/services/, lib/widgets/), Claude has to piece together what each feature does by jumping between folders. It makes mistakes. It creates files in the wrong place. It generates code that doesn't conform to the pattern used in the rest of the app.
The fix is a feature-first structure. Each feature is a self-contained module. Everything Claude needs to understand the transfer flow, for example, lives inside lib/features/transfer/.
lib/
├── core/
│ ├── constants/
│ ├── errors/
│ ├── router/
│ └── theme/
├── features/
│ ├── auth/
│ │ ├── data/
│ │ │ ├── models/ # Freezed models
│ │ │ └── repositories/
│ │ ├── presentation/
│ │ │ ├── screens/
│ │ │ ├── widgets/
│ │ │ └── providers/ # Riverpod providers
│ │ └── auth.dart # barrel export
│ ├── transfer/
│ │ ├── data/
│ │ ├── presentation/
│ │ └── transfer.dart
│ └── wallet/
│ ├── data/
│ ├── presentation/
│ └── wallet.dart
└── main.dart
This structure tells Claude immediately: "Everything for the transfer feature is in lib/features/transfer/"When you ask it to 'add a beneficiary validation to the transfer flow,' it knows exactly where to look and where to create new files.
It also maps cleanly to Riverpod with code generation. Each feature's providers live close to the screens that use them, which means build_runner output lands in the right place, too.
CLAUDE.md is arguably the most important file in your Claude Code setup. It's loaded at the beginning of every session. It remains in context throughout the conversation, helping Claude stay aligned with your project's architecture, conventions, and development practices no matter how long the session becomes.
Create it at the root of your project:
touch CLAUDE.md
Here's a template shaped for a Flutter/Riverpod project:
# My Flutter App
## Commands
- `flutter pub get` — install dependencies
- `dart run build_runner build --delete-conflicting-outputs` — generate code
- `flutter analyze` — run linter
- `flutter test` — run tests
- `flutter run` — start dev build
## Architecture
Feature-first folder structure. Each feature lives in lib/features/<name>/.
State management: Riverpod with @riverpod code generation (AsyncNotifier pattern).
HTTP: Dio with interceptors in lib/core/network/.
Navigation: GoRouter with named routes defined in lib/core/router/.
Models: Freezed + JsonSerializable. Run build_runner after any model change.
## Conventions
- All monetary amounts in the smallest unit (e.g. kobo for NGN), stored as int — never use doubles for money
- Use ref.invalidate() not ref.refresh()
- No business logic in widgets — all logic goes in notifiers or repositories
- Widget files contain only one public widget per file
- Barrel exports via feature.dart in each feature root
- Prefix private widgets with an underscore
## What NOT to do
- Do not add new packages without asking first
- Do not modify *.g.dart or *.freezed.dart files directly — regenerate with build_runner
- Do not put API calls directly in notifiers — always go through the repository layer
A few things to note about this file:
Keep it honest: If your conventions don't match what's actually in the codebase, Claude will get confused. The CLAUDE.md should reflect how the code actually works today, not aspirationally.
The "What NOT to do" section matters: AI assistants are optimistic. They'll solve the problem in front of them without thinking about side effects. Explicitly telling Claude what to avoid saves a lot of cleanup.
Don't make it too long: Every line in CLAUDE.md costs tokens on every single turn of every session. Put team-wide, always-relevant rules here. Everything else should be a skill (covered next).
Let's look inside a feature in more detail, using a wallet feature as an example:
lib/features/wallet/
├── data/
│ ├── models/
│ │ ├── wallet.dart # Freezed model
│ │ ├── wallet.freezed.dart # Generated
│ │ ├── wallet.g.dart # Generated
│ │ └── transaction.dart
│ └── repositories/
│ ├── wallet_repository.dart # Abstract class
│ └── wallet_repository_impl.dart
├── presentation/
│ ├── screens/
│ │ ├── wallet_screen.dart
│ │ └── transaction_history_screen.dart
│ ├── widgets/
│ │ ├── balance_card.dart
│ │ └── transaction_tile.dart
│ └── providers/
│ ├── wallet_provider.dart
│ └── wallet_provider.g.dart # Generated
└── wallet.dart # Barrel export
And here's what a clean Riverpod provider looks like in this structure:
// lib/features/wallet/presentation/providers/wallet_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../data/models/wallet.dart';
import '../../data/repositories/wallet_repository.dart';
part 'wallet_provider.g.dart';
@riverpod
class WalletNotifier extends _$WalletNotifier {
@override
Future<Wallet> build() async {
return ref.watch(walletRepositoryProvider).getWallet();
}
Future<void> refreshBalance() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(
() => ref.read(walletRepositoryProvider).getWallet(),
);
}
}
When Claude Code sees this pattern repeated across multiple features, it learns to replicate it. The more consistent your structure, the better Claude's output matches what you'd write yourself.
Skills are reusable instruction packs that Claude Code loads when they're relevant. They live in .claude/skills/<name>/SKILL.md and can be invoked manually with /skillname or triggered automatically when Claude recognises the right context.
A simple way to think about a Skill is as a specialist on your team. Imagine working with a designer, a QA engineer, and a security expert. You don't explain their entire job every time you need their help. Each person already knows their responsibilities and follows a defined process.
Skills work the same way. Instead of repeatedly telling Claude how to generate Riverpod providers, write tests, or review security concerns, you package those instructions into a Skill and let Claude load them whenever they're needed.
Think of a Skill as a saved recipe. Instead of writing out the ingredients and cooking steps every time you want to make a meal, you keep the recipe in one place and reuse it whenever needed.
Skills do the same thing for development workflows. They allow you to save a set of instructions once and have Claude follow them consistently every time a similar task comes up.
The key thing to understand is that the description field is what triggers a skill. Claude evaluates it on every turn and decides whether the current task matches. Because of this, you should describe it using the same verbs that developers actually type in real workflows, like build, commit, release, or fix lint, instead of documentation-style language.
Before you write a skill, think about the tasks you perform over and over again. A good skill captures a workflow you already know by heart. If you find yourself giving Claude the same instructions every session, such as "run flutter analyze, then run build_runner, then execute the tests," that's a good candidate for a skill.
Start with one task. Keep the steps in the exact order you expect Claude to follow, and clearly define what a successful outcome looks like. Don't try to cover every possible edge case. The goal is to automate your normal workflow so Claude can handle the repetitive work consistently, while you step in only when something unexpected happens.
mkdir -p .claude/skills/flutter-release
touch .claude/skills/flutter-release/SKILL.md
---
name: flutter-release
description: |
Use this skill when building a release APK or preparing the app for deployment.
Triggers on: "build release", "generate apk", "prepare release", "release build".
allowed-tools: Bash Read
---
# Flutter release checklist
Run these steps in order. Do not skip any step.
1. Run `flutter pub get`
2. Run `dart run build_runner build --delete-conflicting-outputs`
3. Run `flutter analyze` — fix every error before proceeding. Do not continue with warnings treated as errors.
4. Run `flutter test` — if any test fails, fix it before continuing
5. Run `flutter build apk --release`
6. Confirm build output at `build/app/outputs/flutter-apk/app-release.apk`
7. Create a git commit: `chore: release build vX.X.X`
If any step fails, stop and report the error clearly. Do not skip ahead.
Now, whenever you type "prepare a release" or "build the apk"Claude follows this checklist without you having to remind it of the steps.
mkdir -p .claude/skills/commit
touch .claude/skills/commit/SKILL.md
---
name: commit
description: |
Use when committing changes or writing a commit message.
Triggers on: "commit", "git commit", "commit changes", "write a commit message".
---
Follow Conventional Commits format:
Types: feat | fix | chore | refactor | docs | test | perf
Format: `type(scope): short imperative summary`
Rules:
- Subject line max 72 characters
- Imperative mood — "add" not "added", "fix" not "fixed"
- Scope = the feature name (auth, transfer, wallet, cards)
Examples:
- `feat(transfer): add beneficiary validation on amount input`
- `fix(wallet): correct kobo-to-naira display conversion`
- `chore(deps): upgrade riverpod to 2.6.1`
Always run `flutter analyze` before committing. Never commit with lint errors.
Skills support a powerful trick: you can inject live shell output directly into the skill body using !`command` syntax. Claude receives the output as part of the skill, not as a separate step.
For example, you could embed something like !git status inside a skill, so Claude always sees the current state of your repository when applying that skill. In a Flutter workflow, you could also use something like !flutter test so the skill dynamically includes the latest test results before Claude suggests fixes or improvements.
---
name: sprint-status
description: |
Use when asked about current status, what's left to do, or what changed.
---
## Current git status
!`git status --short`
## Uncommitted changes
!`git diff --stat HEAD`
## Recent commits
!`git log --oneline -10`
## Lint status
!`flutter analyze 2>&1 | tail -20`
Review the above and give a concise summary of: what's done, what's broken, and what needs attention before the next commit.
Type /sprint-status and Claude gets a live snapshot of your project state before responding.
/loop is a built-in Claude Code skill that reruns a task repeatedly until a condition is met. It's the difference between "fix this lint error" (one shot) and "fix all lint errors" (autonomous loop).
For example, instead of running a one-time prompt like “fix this lint error,” you would use /loop fix lint errors in this Flutter project until there are no warnings left. Claude will then repeatedly check the output, apply fixes, and recheck until the condition is satisfied.
A more realistic Flutter workflow could look like /loop run flutter analyze and fix all reported issues until analysis passes clean. In this case, Claude keeps running analyses, fixing issues, and revalidating until the project reaches a clean state.
It's worthy of note here that a/loop and a Skill solve two different problems, and it helps to think of them like this:
A Skill is knowledge.
A Loop is behavior over time.
The pattern is always the same: tell Claude what to run, what to check, and when to stop.
/loop
Run flutter analyze.
If there are any errors or warnings, read each one carefully and fix it.
Run flutter analyze again.
Continue until flutter analyze reports zero issues.
Do not move on while there are errors remaining.
/loop
Run: flutter test --name "WalletNotifier"
If the test fails, read the failure output carefully.
Make the minimal code change required to fix the failure.
Do not change the test itself.
Run the test again.
Stop when the test passes with no errors.
/loop
Look at the Figma spec notes in CLAUDE.md under "Remaining screens".
Pick the next incomplete screen.
Build the screen following the architecture pattern in lib/features/wallet/presentation/.
After building, run flutter analyze and fix any issues.
Add a comment `// DONE` at the top of the completed screen file.
Move to the next screen.
Stop after completing 3 screens.
A word of caution: /loop is powerful, but give Claude a clear stop condition. "Keep going until it's perfect" is not a stop condition. "Stop when flutter analyze and flutter test both pass with zero issues." is.
Subagents are isolated Claude instances that run a task in their own context window and then return only a summary to the main session. This changes how you think about working with Claude Code on a multi-screen project.
A simple way to understand it is to imagine building a full Flutter app with multiple screens. Without subagents, you would design the home screen, then the profile screen, then settings, all in one long conversation. Over time, the context gets heavier, and Claude starts losing focus on earlier decisions.
With subagents, it's like giving each screen to a different engineer. One works on the home screen, another builds the profile screen, and another handles settings. Each one works independently, follows the same project rules, and reports back only when the screen is ready. You then combine their output into the main project without losing clarity or consistency.
Create a file at .claude/agents/screen-builder.md:
---
name: screen-builder
description: Builds a single Flutter screen following the app's feature-first Riverpod architecture
model: claude-sonnet-4-6
tools: [Read, Write, Bash, Glob]
---
You are a Flutter engineer building a screen for a fintech app.
Before building anything:
1. Read lib/features/wallet/presentation/screens/wallet_screen.dart to understand the existing screen pattern
2. Read CLAUDE.md for conventions and architecture rules
3. Read the feature's existing providers in the presentation/providers/ folder
When building the screen:
- Follow the exact same structure as the existing screens
- Use AsyncValue pattern for loading/error/data states
- No business logic in the widget — all state goes through the provider
- Every monetary amount displayed in naira but stored in kobo (divide by 100 for display)
- Use GoRouter for navigation, not Navigator.push
After building:
- Run flutter analyze on the file
- Fix any errors
- Return a summary: file path created, provider used, any decisions made
In your main session, you can now say:
Use the screen-builder subagent to build the Transaction History screen.
The screen should show a list of transactions from the WalletNotifier provider.
Each item should display: amount (formatted), description, date, and status badge.
Claude dispatches the subagent, which reads your existing code for context, builds the screen following your patterns, fixes any lint errors, and returns a clean summary, without cluttering your main thread with every intermediate step.
You can also run multiple subagents simultaneously for truly parallel work:
Dispatch three screen-builder subagents in parallel:
1. Transaction History screen (list of transactions)
2. Send Money screen (amount input + recipient selection)
3. Wallet Top-Up screen (amount input + payment method)
Each should follow the existing wallet feature patterns.
Report back when all three are complete.
Skills and subagents influence how Claude thinks and plans, but hooks are different. Hooks are deterministic. They run automatically at specific lifecycle events, no matter what Claude decides to do. This makes them useful for enforcing hard rules in your workflow.
A simple way to understand it is to think of hooks as guards in a real engineering pipeline. For example, before any code is committed, a PreToolUse hook can run to check formatting or block unsafe changes. After a tool runs, a PostToolUse hook can validate the output. When a session ends, a Stop hook can trigger cleanup tasks or logging. Other events, like SessionStart, PreCompact help you initialize context or manage memory before Claude continues working.
In practice, hooks are how you enforce consistency. While Skills and subagents guide Claude’s behavior, hooks ensure certain actions always happen at the right moment, without relying on Claude to “remember” or “decide.”
Generated files like *.g.dart and *.freezed.dart should never be edited manually — they get overwritten by build_runner. This hook blocks Claude from writing to them:
Create .claude/hooks.json:
{
"PreToolUse": [
{
"matcher": "Write|Edit",
"command": "bash -c 'if [[ \"\(CLAUDE_TOOL_INPUT_PATH\" == *.g.dart ]] || [[ \"\)CLAUDE_TOOL_INPUT_PATH\" == *.freezed.dart ]]; then echo \"Blocked: Do not edit generated files. Run build_runner instead.\"; exit 1; fi'"
}
]
}
This hook runs flutter analyze before Claude considers its turn complete, catching lint errors before they accumulate:
{
"Stop": [
{
"command": "bash -c 'result=\((flutter analyze 2>&1); if echo \"\)result\" | grep -q \"error •\"; then echo \"Flutter analyze found errors. Fix before stopping:\"; echo \"$result\"; exit 1; fi'"
}
]
}
Now Claude can't finish a turn if there are lint errors. It gets blocked and has to fix them first.
Here's what a typical feature development session looks like when all of this is configured:
/sprint-status
Claude reads live Git status, recent commits, and current lint output, then summarises what needs attention.
I need to build the beneficiary management feature.
Users should be able to save, view, and delete beneficiaries for the transfer flow.
Start with the data layer — Freezed model and repository interface.
Claude reads your CLAUDE.md and existing feature patterns, then builds the model and repository in the right place, following your conventions.
Use the screen-builder subagent to build:
1. BeneficiaryListScreen — shows saved beneficiaries with search
2. AddBeneficiaryScreen — form with account number and bank selection
3. BeneficiaryDetailScreen — shows details with delete option
/loop
Run flutter analyze.
Fix all errors.
Run flutter test.
Fix any test failures.
Stop when both pass with zero issues.
Commit the beneficiary feature
The commit skill triggers, runs analyze one more time, and creates a correctly-formatted conventional commit message.
If there's one key takeaway from all of this, it's that Claude Code isn't just about prompting. It's about setup. The quality of its output is shaped far more by what you define about your project upfront than by what you type in the moment.
This is also what separates vibe coding from real AI-assisted engineering. Without structure, you end up guessing and reacting, which feels fast but breaks down quickly.
With the right setup, Claude becomes a pair programming partner that follows your conventions and handles execution while you focus on decisions that actually require engineering judgment. That shift is what lets you spend less time fixing generated code and more time solving the problems that matter.
The payoff compounds. A CLAUDE.md takes 20 minutes to write. A skill for your release flow takes 10 minutes. But both of those pay for themselves the first time Claude correctly follows your process without you having to walk it through every step.
Start small: write your CLAUDE.md this week. Add one skill for the task you repeat most — committing, releasing, or running lint. Then, when you're comfortable, try a /loop on your next test-fixing session. The rest follows naturally.
The goal isn't to let AI write all your code. It's to stop spending your limited engineering time on the parts that don't require your judgment, and to spend more of it on the parts that do.