abelcastro.dev

I created a side project to grow as a developer. It became something I am proud of

2026-07-13

thebest.ink

When to write an agent rule, and when not

2026-06-11

toolingdocumentationcoding-guidelines

Trying fastapi.cloud: a small deploy and the cron gap

2026-05-21

sqlalchemyneonhosting
1
23
...
1213

Abel Castro 2026 - checkout the source code of this page on GitHub - Privacy Policy

A year ago I started a side project. The public reason was a real problem. Finding a tattoo artist who cares about your tattoo is harder than it should be. The good ones are overbooked, and when you search, you get a list of studios with no way to compare them.

The private reason mattered more to me. I wanted to grow as a developer, and for that I needed a real problem, not a tutorial. This is what a year of that looked like, and what it taught me.

It looked small

When I started, the whole thing looked simple. A Django API and some kind of frontend. Let people search for studios by place and by style. That was it. I did not even plan to let users book appointments.

The simple idea was not simple. That gap is where most of the learning happened.

The hardest part was having no data

A search platform with an empty database is useless. Nobody visits, so no artist signs up, so there is nothing to search, so nobody visits. I could not wait for artists to fill it.

So I built the supply side first, before a single artist signed up. I wrote a pipeline that gets studios from a map provider. The first step gets the address, coordinates, and website. The second step reads each studio website as plain text and passes it to an LLM, which returns structured information: the artists, the styles, and so on. That data gets saved as a studio profile.

I designed the map provider to be interchangeable. I started with Google Maps, but the pipeline does not depend on it. I also made the region size configurable and kept a history of every scan, so I could grow the coverage area step by step instead of all at once.

The result is that when an artist arrives, their profile already exists. They claim it instead of building it. This is the part I am most happy with, because it solved a real problem and not a toy one.

Six results, no way to choose

Once search worked, a new problem appeared. Search for studios in Salzburg with the style Blackout and you might get six results. Which one do you pick? A plain list does not help.

I added a score from 0 to 100 based on reviews. The naive version, a simple star average, is unfair. A studio with one five-star review would beat a studio with fifty reviews at 4.8 stars. That is wrong. So I used a Bayesian ranking, which moves scores with few reviews toward the average until there are enough reviews to trust them. I called it the TheBest.Ink Index.

I treated it like real work

I kept telling myself this is only a side project. But if the goal was to grow as a developer, then building it carelessly would defeat the point. So I used the same practices I use at my job:

  • Test coverage around 80 percent, kept high from the start.
  • End-to-end tests with Playwright for the core flows, the ones that must not break.
  • A staging environment, because I do not test in production.
  • Ansible to manage deployment, so moving to another provider or scaling later is a configuration change, not a rewrite.

Here is what a year of that looked like.

GitHub contribution graph showing a year of activity

This is my GitHub activity for the last year. Around 97 percent of it is one private repository, TheBest.Ink. The gaps are real. This was spare-time work, at night, early in the morning, and on some holidays, next to the job that pays the bills.

Writing for humans and agents at the same time

As the project grew, the number of cases became hard to keep track of. Discovery generates profiles, artists claim them, users send requests, artists accept, users leave reviews, artists reply. Each of those has edge cases.

I wrote most of the code and docs AI-assisted, as if I were working in a team with other developers. That changed how I think about two things.

The first is documentation. The same document has to serve a human who is new to the code and an agent that needs context to do the work. Keeping two separate sets of docs means they drift apart. The second is project layout. The project started as four separate repositories, and both I and the agents kept losing context when moving between them. Merging everything into one monorepo fixed that. The agent could see the whole codebase, and so could I.

I wrote two posts about what I learned there: "Customer Journeys Belong Next to Your Code" and "The Problem With Splitting Human and Agent Docs".

Where it stands now

The full flow works end to end and is covered by tests. Discovery creates a profile, an artist claims it and verifies they own it, the artist manages their data, a user sends an appointment request, the artist accepts, the user leaves a review, and the artist can reply. The design is becoming consistent across the apps, and the whole thing is starting to feel like a solid product.

Here is a short video of the product in use.

Finding a studio and its artists, then an artist profile with the artist's own data, reviews, and portfolio images.

The goal was to grow as a developer, and it worked. Along the way it became something I did not expect: a product I am proud of. A year ago it was an idea and a rough plan. Today it runs end to end, and I built every part of it myself. That is what I am happy about. You can try it at TheBest.Ink.

Most AI coding agents read a rules file before they touch your code. Claude Code reads CLAUDE.md and the files under .claude/rules. Cursor and Copilot have their own versions. The format differs, but the idea is the same: a place where you write down how code in this project should look, and the agent reads it on every session.

That place fills quickly. Once you have it, almost any coding guideline looks like it belongs there. Naming, commit format, how to structure a NestJS service, security advice, testing habits. It all sounds reasonable as a rule.

From what I have seen, this is where rule files go wrong. They become a second copy of every guideline the team has ever written, and most of those lines do nothing. This post is about a simple test for which lines belong in a rule and which have a better home.

A rule is the last home, not the first

I treat an agent rule as the last place I put guidance, not the first. Before a line becomes a rule, it has to fail every other home.

The test I use has two parts. Keep a line as a rule only if both are true:

  • Removing it would change what the agent actually does in this codebase.
  • A tool cannot enforce the same thing.

If a tool can enforce it, the tool is a better home. If removing it changes nothing, it should not be there at all. What survives both questions is small, and that small set is what a rule is for.

If a tool can enforce it, let the tool do it

This is the biggest group. A lot of what ends up in rule files is something a linter or a check can verify.

Commit message format is the clearest example. Writing "use the imperative, start with a capital letter" as a rule asks the agent to remember a convention. commitlint just enforces it:

{
  "extends": ["@commitlint/config-conventional"]
}

Now the format is not advice, it is a check that fails when the message is wrong. The same goes for naming. A rule that says "boolean variables start with is, has, or should" can be an eslint rule instead:

{
  "@typescript-eslint/naming-convention": [
    "error",
    {
      "selector": "variable",
      "types": ["boolean"],
      "format": ["PascalCase"],
      "prefix": ["is", "has", "should"]
    }
  ]
}

When a tool enforces a guideline, something useful happens. The tool becomes the only definition. There is no prose copy that can disagree with it later. A failing check is also the most reliable documentation I know, because you cannot ignore it. It stops the merge.

This fits how I already like to work. I rely on tests and CI to catch mistakes rather than on documents people are supposed to remember. An agent rule that repeats a lint rule is just a less reliable copy of something the linter already does.

If the model already does it, delete it

The second group is the one that feels the most useful and is usually the most useless. These are general engineering principles. "Simpler designs are more secure." "Prefer composition." "Write tests." "Use clear names."

They are all true. That is exactly why they do not belong in a rule. A current model has read this advice many thousands of times. It already writes simpler code and clearer names by default. Repeating the principle does not change the output, because the model was going to do it anyway.

These lines are also not free. Every line in a rule file is read on every session. It costs tokens, and it competes for the model's attention with the few lines that actually matter. A rule file that is mostly general advice makes its own useful parts harder to notice.

There is also no place to stop once you start. If "simpler is more secure" earns a place, so does every other principle anyone has ever agreed with. You can keep adding them forever, and each one looks justified on its own. When a category has no natural boundary, that is usually a sign the category is wrong.

If it describes one part of the code, put it next to that code

Some guidelines are real and specific, but they only apply to one area. How to structure a NestJS service. How to write a React component in the admin app. These are worth writing down. They just do not need to live in a global rule.

A README next to that code is a better home. The agent reads it when it works in that folder, and so does the next developer who opens it. One file, both readers, sitting where the work happens. A global rule puts that same guidance far away from the code it describes, which is how it starts to disagree with reality over time. I wrote more about keeping a single document for both humans and agents in an earlier post.

What is actually left

After tooling, deletion, and module docs, what remains is small. It is the guidance that is specific to this project, that a tool cannot catch cheaply, and that the model would get wrong without being told.

An example from the kind of work I do: in one project the convention is to pass a whole entity into a service method instead of passing just its id. A linter will not catch that, the model does not do it by default, and getting it wrong causes real confusion later. That is a genuine rule. It changes what the agent does, and nothing else enforces it.

Notice that this rule is not verifiable. No check confirms it, and that is fine. The test was never "can a tool verify it." The test was "does removing it change what the agent does here." Some judgment conventions pass that test even though no tool can enforce them. Those are the rules worth keeping, and there are not many of them.

The short version

Before you write an agent rule, ask two questions. Can a tool enforce this? Then it belongs in the tool. Would removing it change nothing? Then delete it. What is left, the project-specific guidance that a tool cannot catch and the model would otherwise get wrong, is the whole job of a rule file.

In the projects I work on, that final list is short. A rule file that stays short is doing its job. One that keeps growing is usually collecting things that belong somewhere else.

I got beta access to FastAPI Cloud, the new hosting provider from the FastAPI team. This is a short report of what I deployed and how it went. It is not a full review.

The project I used is a hobby test project. Nothing critical. If it is unavailable I do not care. That made it a good fit for trying a beta hosting provider.

For context: my usual way to deploy a Python app is Docker on a VPS or a dedicated server. I have used Digital Ocean App Platform in the past. The developer experience was great, but I stopped using it because the price was not worth it for my use case. I have not tried Render or other managed Python hosting providers. So this is not a full comparison against managed hosting in general. It is just my experience trying FastAPI Cloud.

What I deployed

I part of the django app behind abelcastro.dev is a small API fetching and serving sports (actually just football) data. To try FastAPI Cloud, I extracted that part into a standalone FastAPI app. The code is here: github.com/abel-castro/sports-api and the live version of it can be found here sports-dashboard.abelcastro.dev/.

The migration itself was easy with AI help. The original code was simple, and the move to FastAPI plus SQLModel/SQLAlchemy was straightforward.

The setup: easy start, with Vercel as the reference point

In his blog post about starting FastAPI Cloud, Sebastian Ramirez names Vercel, Netlify, and Cloudflare in the frontend world as the kind of developer experience he wanted to bring to Python deployment. Vercel was also the comparison I had in mind when I tried it.

Login and creating a first app were straightforward. It is not as seamless as Vercel today, but for a beta the starting experience is impressive.

One thing I liked is the direct integration with database providers. I connected the app to a Neon Postgres database from inside the FastAPI Cloud setup, and that worked well.

A small SQLAlchemy and Neon detail

After the app was live, I noticed intermittent database connection errors. This was not a FastAPI Cloud problem. It is how Neon works. Neon suspends idle compute, so connections in the SQLAlchemy pool can become stale and fail on the next request.

The fix was to pass two extra options to create_engine:

engine = create_engine(
    settings.database_url,
    pool_pre_ping=True,
    pool_recycle=1800,
)

pool_pre_ping=True makes SQLAlchemy check that a connection is alive before using it. pool_recycle=1800 tells the pool to discard connections older than 30 minutes. After this change the errors stopped.

The commit is here if you want to see it: Fix db connection errors.

The main friction: no scheduled tasks yet

The sports API needs a periodic job to refresh data. As far as I can tell, FastAPI Cloud does not support scheduled tasks at the moment.

For this hobby project I used a workaround. A scheduled GitHub Actions workflow calls a protected endpoint on the API. The endpoint checks a token passed in a header, and the token is stored as a GitHub secret. The workflow file and the endpoint are in the same repo linked above.

I want to be clear: this is a hobby pattern, not how I would do it for a real project. GitHub schedules can be delayed under load, there are no built-in retries, long jobs do not fit inside an HTTP request, and observability is split between two platforms. For a real project I would use a task queue with a worker (Celery, RQ, ARQ, Dramatiq) or a managed scheduler.

So my open question for the FastAPI Cloud team is: are proper scheduled tasks on the roadmap?

Smaller things I missed

Right now you get an auto-assigned subdomain on FastAPI Cloud and there is no custom domain option yet. For a beta this is fine, and I would not complain about it. It is just something to know.

Summary

For a beta, FastAPI Cloud is impressive. The login and first deploy are easy, and the Neon integration works well (with the small SQLAlchemy tweak above). The one real gap I found was scheduled tasks.

Looking ahead

I am looking forward to seeing how the product evolves, and what pricing they introduce. Hosting costs money, and at some point the team has to make money from it. In any case, in my opinion they are making a great start.