Mert
Maintaining Open Source in the AI Era
I’ve been maintaining a handful of open source packages lately: mailview, mailjunky (in both Python and Ruby), and recently dusted off an old Ruby gem called tvdb_api. The experience has been illuminating – not just about package management, but about how AI is changing open source development in ways I’m still processing.
The Packages
mailview started because I missed letter_opener from the Ruby world. When you’re developing a web application, you don’t want emails actually being sent – you want to inspect them locally. In Rails, letter_opener handles this beautifully. In Python? The options were less elegant. So I built mailview: add the middleware to your FastAPI or ASGI app, and every outgoing email gets captured and displayed in a clean browser UI at /_mail.
mailjunky is the SDK for a transactional email service I use. I wrote both Python and Ruby versions because I work across both ecosystems and wanted a consistent interface. The Python version powers the email notifications in whatisonthe.tv, sending watchlist digests and update notifications.
tvdb_api is older. I wrote it years ago when I needed to fetch TV show metadata from TheTVDB. Recently I came back to it and found… well, it had aged poorly.
When Code Goes Stale
Opening tvdb_api after several years was humbling. The code still worked, technically, but it was written for a different era of Ruby. No keyword arguments where they’d make sense. Inconsistent error handling. Dependencies that had moved on. The API it wrapped had evolved through multiple versions.
This is the reality of open source maintenance that doesn’t get discussed enough. You release something, people use it, and then life happens. You move to different projects. The ecosystem evolves. What was idiomatic becomes dated.
I spent a weekend modernising tvdb_api. Keyword arguments throughout. Proper exception hierarchies. Updated API support. Modern testing practices. The gem that emerged was recognisably the same tool but felt contemporary rather than archaeological.
The irony isn’t lost on me that I did this modernisation with Claude’s help. More on that shortly.
Ruby vs Python: A Tale of Two Package Managers
Here’s where things get interesting. Publishing to RubyGems versus PyPI in 2026 is a study in contrasts.
RubyGems feels… creaky. The authentication flow is clunky. The web interface looks dated. I’ve hit mysterious failures that resolved themselves without explanation. The documentation assumes knowledge that new maintainers might not have. It works, but it feels like infrastructure that’s been maintained rather than evolved.
PyPI, meanwhile, has embraced Trusted Publishing. You configure your GitHub repository as a trusted publisher, and your GitHub Actions workflow can publish packages without storing API tokens as secrets. The authentication happens via OpenID Connect – GitHub attests to the identity of the workflow, PyPI trusts that attestation.
The practical difference is significant:
# PyPI with Trusted Publishing - no secrets needed
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
Compare this to RubyGems, where you’re still managing API keys, storing them as secrets, and hoping you’ve configured the authentication correctly.
For mailview and mailjunky-python, I set up Trusted Publishing and the release process is now: tag a release, push the tag, and GitHub Actions handles the rest. For the Ruby packages, there’s more ceremony involved, and I’ve had releases fail for reasons that weren’t immediately clear.
I don’t want to overstate this – RubyGems works and millions of packages depend on it. But PyPI’s investment in modern authentication patterns has made the maintainer experience noticeably better.
AI Changed Everything (And I’m Not Sure How I Feel About It)
Here’s where I need to be honest about something uncomfortable.
All of these packages were built with significant AI assistance. Claude helped write the initial implementations. It helped with test coverage. It modernised tvdb_api’s Ruby patterns. It debugged edge cases I would have spent hours tracking down.
The productivity gains are real. What might have taken weeks of evening and weekend work happened in days. Features that I might have cut for time made it in. Test coverage that I might have skipped got written.
But.
I look at open source differently now, and I’m not sure it’s for the better.
When I review pull requests on other projects, I find myself wondering: did a human think through this change, or did an AI generate it and a human click approve? When I encounter a bug in a library, I wonder: was this edge case missed because the AI-generated tests didn’t think to cover it?
The social contract of open source has always been implicit: someone cared enough about this problem to spend their limited time solving it. That investment of human attention was a signal of quality, of thoughtfulness. It’s why we trusted small libraries maintained by individuals – someone was paying attention.
AI disrupts this calculus. I can now create a package in an afternoon that would have taken weeks. But has the thoughtfulness kept pace with the speed? I’ve tried to maintain the same standards I would have without AI assistance, but I’m not certain I’ve succeeded. And I definitely can’t evaluate whether other maintainers have.
The Testing Paradox
This concern crystallises around testing. AI is excellent at generating tests. Given a function, Claude will produce comprehensive test cases covering happy paths, edge cases, error conditions. The coverage numbers look great.
But test generation isn’t the same as test thinking. When I write tests manually, I’m forced to think about how the code will be used. What assumptions am I making? What could go wrong? What would a user reasonably expect?
AI-generated tests cover the code that exists. Human-written tests often reveal the code that should exist. There’s a subtle but important difference.
I’ve tried to mitigate this by reviewing AI-generated tests carefully and adding cases that emerge from my understanding of how the package will be used in practice. But I’d be lying if I said every test got that level of attention.
What I’ve Learned
A few things have become clearer through this experience:
Maintenance is the hard part. Writing the initial code is the easy bit. Keeping it current, fixing bugs, responding to issues, updating dependencies – that’s where open source lives or dies. AI helps with maintenance too, but it doesn’t solve the fundamental problem of limited human attention.
Modern tooling matters. PyPI’s Trusted Publishing removed a category of release friction entirely. When releasing is easy, releases happen more often. When it’s painful, packages go stale. This is boring infrastructure work, but it has real effects on ecosystem health.
AI is a force multiplier, not a replacement. The packages I’m happiest with are ones where I used AI to handle the mechanical work while staying engaged with the design decisions. The ones where I let AI drive too much feel… hollow, somehow. Technically correct but lacking in soul.
Transparency matters. I’ve started noting in my projects when AI was used significantly in development. Not as a warning, but as context. Users can decide for themselves what that means.
Where This Leaves Me
I’m going to keep maintaining these packages. People use them, and I use them myself. The experience has made me more thoughtful about what I’m building and why.
But I watch the open source ecosystem with more concern than I used to. The barriers to creating packages have dropped dramatically. That’s good for getting solutions out there quickly. I’m less sure it’s good for the long-term health of software we all depend on.
Maybe I’m worrying about nothing. Maybe AI-assisted development will become so normal that these concerns seem quaint. Maybe the tooling will evolve to help us distinguish thoughtful work from generated slop.
For now, I’m trying to hold myself to the standards I had before AI assistance was available, while being honest that the assistance exists. It’s an imperfect balance, but it’s the best I’ve found.
The packages are on PyPI and RubyGems if you want to use them. They work. I’ve tested them. Claude helped.
Make of that what you will.
The LLM Is the New Parser
I spent the early 2000s writing parsers. HTML scrapers with regex that would make you cry. XML deserializers that handled seventeen flavours of “valid”. CSV readers that knew a comma inside quotes wasn’t a delimiter.
The pattern was always the same: the world gives you garbage, you write defensive code to extract meaning.
Then APIs won. JSON with schemas. Type-safe clients. The parsing era ended. We’d civilised the machines.
Now I’m building Indexatron, a local LLM pipeline for analysing family photos. LLaVA looks at an image, I ask for JSON, and I get… this:
json
{
“description”: “A dog sitting on a wooden floor”,
“categories”: [“dog”],
“people”: [
{“estimated_age”: “Beer is an alcoholic beverage”}
]
}
python
The model wrapped JSON in markdown code fences. It put beer in the people array with an age field containing a Wikipedia definition. Sometimes the braces don’t balance. Sometimes it returns YAML when you asked for JSON.
Sound familiar?
We’re back to parsing unreliable output. The only difference is the garbage now comes from a neural network instead of a web server. The defensive patterns are identical:
# Strip markdown code blocks
if response.startswith("```
"):
response = response.split("
```")[1]
if response.startswith("json"):
response = response[4:]
# Balance braces
open_braces = response.count("{")
close_braces = response.count("}")
if open_braces > close_braces:
response += "}" * (open_braces - close_braces)
# Parse and pray
try:
data = json.loads(response)
except JSONDecodeError:
data = {"raw": response, "parsed": False}
Twenty years of progress and I’m back to “try to parse it, catch the exception, return something usable anyway.”
The irony isn’t lost on me. We built trillion-parameter models that can write poetry and explain quantum physics, but they can’t reliably close a curly brace. The solution? Wrap them in the same defensive parsing code we wrote for Internet Explorer’s HTML in 2003.
The LLM is the new parser. It turns unstructured data (images, documents, audio) into semi-structured output that you then parse into actually-structured data.
The more things change.
Indexatron: Teaching Local LLMs to See Family Photos
Status: ✅ SUCCESS
Hypothesis: Local LLMs can analyse family photos with useful metadata extraction
I’ve been building the-mcculloughs.org – a family photo sharing app. The Rails side handles uploads, galleries, and all the usual stuff. But I wanted semantic search – not just “photos from 2015” but “photos at the beach” or “pictures with grandma.”
The cloud APIs exist. But uploading decades of family photos to someone else’s servers? Hard pass.
Time for a science experiment – two apps working together.
The Experiment
I called it Indexatron 🤖
The goal: prove that Ollama running locally with LLaVA:7b and nomic-embed-text can:
- Analyse photos – Extract descriptions, detect people/objects, estimate era
- Generate embeddings – Create 768-dimensional vectors for similarity search
- Process batches – Handle multiple images with progress tracking
Test Results
| Metric | Value |
|---|---|
| Images Processed | 3/3 |
| Failed | 0 |
| Total Time | 40.82s |
| Avg Time/Image | ~13.6s |
Sample Outputs
🐕 family_photo_03.jpg
- Description: “A tan-coloured Labrador Retriever is sitting on a wooden floor indoors”
-
Categories:
["dog"] - Mood: calm
- Processing Time: 14.73s
🍺 family_photo_02.jpg
- Description: “A photo of a bottle of beer and a glass with frothy white head on top, placed on a table at a restaurant”
- Location: Indoor restaurant
- Objects Detected: Beer bottle (Kingfisher brand), glass with beer
-
Categories:
["beer", "restaurant"] - Processing Time: 14.2s
👔 family_photo_01.jpg
- Description: “A man standing in an indoor conference room during a wedding reception”
- Era Detected: 2010s (medium confidence)
- Person: Male guest, 30s, wearing suit and tie
-
Categories:
["wedding"] - Processing Time: 11.89s
What Worked Well
LLaVA Vision Analysis
- Correctly identified subjects (dog, beer, person)
- Detected specific brands (Kingfisher)
- Estimated era from visual cues
- Provided useful mood/atmosphere descriptions
Embedding Generation
- 768-dimensional embeddings generated for all images
- Based on analysis descriptions (semantic meaning)
- Ready for similarity search when needed
Batch Processing
- Progress bar with Rich library
- Skip existing functionality
- Combined JSON output
Quirks & Learnings
JSON Parsing Required Repair
LLaVA doesn’t always output clean JSON. The analyser needed:
- Code block stripping
- Brace balancing
- Type coercion for nested objects
Model Hallucinations
Some amusing observations:
- The dog photo mentioned “clothing” and “fashion trends for pets” (the dog had no clothes)
- Beer was classified under
peoplearray withestimated_age: "Beer is an alcoholic beverage"
These quirks don’t break the system – robust parsing handles them.
Processing Time
~13.6 seconds per image is acceptable for batch processing. Real-time analysis would need:
- Smaller model (llava:7b is the smallest)
- GPU acceleration
- Or async processing with user feedback
Development Approach
This was parallel development across two codebases – with very different approaches for each.
The Boring Bits: AI Agents for CRUD
The Rails API work? It’s not exciting. Setting up API endpoints, adding pgvector, writing migrations, CRUD operations – I’ve done this hundreds of times. It’s necessary scaffolding, but it’s not where I want to spend my brain cycles.
So I let AI agents handle it. Claude Code with custom agents for code review, test writing, and documentation. The agents handled the boilerplate while I reviewed and approved. This is exactly what AI assistance is good for – augmenting the repetitive work so you can focus on what matters.
The Interesting Bits: README-Driven Development
Indexatron was different. This was an experiment – I needed to understand every piece, make deliberate choices, and document as I went. For this, I used README-driven development:
- Write the README first – Document what the code should do before writing it
- One branch per milestone – Each branch proves one thing works
- Merge only when it works – No moving on until the milestone is complete
- AI for documentation – Let agents help write up the results
README-driven development forces you to think through the design before coding. It’s slower, but you end up with working code and documentation. Perfect for experiments where you need to prove something works.
Development Progress
Indexatron (Python) – The Experiment
README-driven development with one branch per milestone:
| PR | Milestone | What It Proved |
|---|---|---|
| #5 | Project Setup | Foundation ready |
| #1 | Ollama Connection | Local LLM runtime accessible |
| #2 | Image Analysis | LLaVA extracts useful metadata |
| #3 | Embeddings | 768-dim vectors for similarity |
| #4 | Batch Processing | Scalable to many images |
Each branch had to work before moving on. Prove it, merge it, move on.
Rails App – The Integration (Agent-Assisted)
While I focused on Indexatron, AI agents handled the Rails infrastructure:
| PR | Feature |
|---|---|
| #60 | AI Photo Analysis API with pgvector |
Standard API endpoint, database migration, pgvector setup – all the CRUD that’s been done a thousand times before. The agents wrote the code, I reviewed it, tests passed, merged. That’s the right division of labour: agents handle the predictable, humans handle the novel.
Technical Stack
Ollama (local runtime)
├── llava:7b (~4.7GB) - Vision analysis
└── nomic-embed-text (~274MB) - Embeddings
Python 3.11+
├── ollama - API client
├── pydantic - Data validation
├── pillow - Image handling
└── rich - Console output
Next Steps
This proves the concept works. Future integration:
- Rails API – Add endpoint for on-demand analysis
- Database Storage – Save embeddings in PostgreSQL (pgvector)
- Similarity Search – Find “photos like this one”
- Face Recognition – Cluster photos by person (future model)
Conclusion
🤖 The robots can see our photos.
Local LLMs provide a privacy-preserving alternative to cloud APIs for photo analysis. The quality is good enough for family photo organisation, and the 768-dimensional embeddings enable future similarity search features.
Code
| Repository | Description |
|---|---|
| swmcc/indexatron | Python service for local LLM photo analysis |
| swmcc/the-mcculloughs.org | Rails family photo sharing app |
Full experiment results: RESULTS.md
Built with Ollama, LLaVA, and a healthy scepticism of cloud APIs.
Working with Claude: A Senior Developer’s Honest Take
I’ve been using Claude Code as part of my daily development workflow for several months now. This isn’t a breathless endorsement or a dismissive rejection. It’s an honest assessment from someone who’s been writing software professionally for over two decades.
It’s a Tool. Treat It Like One.
Claude is a tool. A genuinely impressive one, but still a tool. It sits in the same category as my text editor, my terminal, and my version control system. I find this framing helpful – not to diminish what it can do, but to approach it practically.
Some of the hype around AI assistants oversells what they are. Junior developers aren’t obsolete. Senior developers aren’t being replaced. What’s actually happening is more interesting: certain categories of work have become dramatically faster, and that changes what’s practical to attempt.
AI assistants are particularly good at boilerplate, repetitive transformations, exploring unfamiliar codebases, and acting as a thinking partner. They still need human judgement for architecture decisions and business context. That’s not a criticism – it’s just understanding where the tool excels.
The Workflow
My setup has evolved over these months. I’ve finally moved away from tmux after years of muscle memory. Ghostty with native splits handles my terminal needs now, and honestly, it’s simpler. One less abstraction layer, one less thing to configure.
A typical session looks like this:
- Left pane: Claude Code running in the terminal
- Right pane: Shell for running tests, git operations, server logs
- Editor: Neovim in a separate window (some habits don’t change)
Claude handles the tedious bits. Writing test scaffolding. Generating boilerplate for new modules. Explaining what some legacy code does before I refactor it. Drafting commit messages (which I usually edit – it’s verbose by default, but that’s easy to fix).
The Game Changers
Three features have fundamentally changed how I work: planning mode, skills, and tasks. These aren’t just conveniences – they’ve shifted how I approach problems.
Planning Mode
Before I discovered planning mode, I’d sometimes watch Claude charge off implementing something before I’d fully thought through the approach. Now, for anything non-trivial, I start in planning mode.
The workflow is: describe what I want to achieve, let Claude explore the codebase, and then review a structured plan before any code gets written. This catches architectural issues early. It surfaces questions I hadn’t considered. It means we’re aligned on approach before investing time in implementation.
For complex features, I’ll spend fifteen minutes in planning mode refining the approach. That investment pays back tenfold by avoiding wrong turns and rework. It’s like having a technical design review built into the workflow.
Skills
Skills are reusable prompts that encode workflow knowledge. I’ve built custom skills for my common tasks: code review, security audit, test generation, and more. Instead of explaining what I want each time, I invoke a skill and it applies consistent standards.
My /review skill knows how to examine git changes against the patterns I care about. My /audit skill applies security checks specific to my tech stack. These aren’t just time savers – they’re consistency enforcers. The review I get at 6pm after a long day is the same quality as the one at 9am.
The ability to chain skills is powerful too. I can run security audit, code review, and test coverage analysis in parallel, then synthesise the results. What used to be a morning’s work happens while I grab a coffee.
Tasks
The task system changed how I approach larger pieces of work. When I’m implementing a feature that spans multiple files or requires several steps, I ask Claude to break it down into tasks.
Each task becomes a tracked unit of work. I can see what’s done, what’s in progress, and what’s blocked. Claude updates task status as it works, so I always know where we are. When I step away and come back, the context is preserved in the task list.
For a recent project, I had a twelve-task implementation plan. Being able to work through it systematically, with clear progress tracking, made a complex change manageable. It’s project management integrated directly into the coding workflow.
What Actually Helps
Exploration: When I’m dropped into an unfamiliar codebase, Claude can trace through call paths faster than I can grep. “Where does this function get called from?” is a question I used to spend twenty minutes on. Now it’s seconds.
Boilerplate: Writing the fourteenth variation of a CRUD endpoint is mind-numbing. Claude handles it, I review and adjust. The code isn’t clever, but it doesn’t need to be.
Documentation: Drafting docstrings, README sections, or API documentation. I edit to match my voice, but having a solid starting point beats a blank page.
Thinking partner: Sometimes I explain a problem to Claude and realise the solution myself while typing. Other times, it suggests an approach I hadn’t considered. Either way, it’s valuable.
Learning unfamiliar libraries: When I need to use an API I haven’t touched before, Claude provides working starting points. Better than Stack Overflow answers from 2019 that may or may not work with current versions.
Where Human Judgement Matters
Architecture: Claude can suggest architectures, and the suggestions are reasonable starting points. But the “right” architecture depends on team size, existing infrastructure, deployment targets, and factors that require human judgement to weigh.
Business logic: When the correct behaviour depends on understanding why the business works a certain way, I need to provide that context. Claude works with what it can see in the code.
Security-critical code: I review generated code carefully when it touches authentication, authorisation, or sensitive data. Trust but verify.
The Biggest Paradigm Shift I’ve Seen
I’ve been doing this long enough to have opinions about paradigm shifts. I remember when version control went from “nice to have” to essential. I watched the industry move from on-premise to cloud. I’ve seen languages rise and fall.
This is different. Not because AI is magic – it isn’t. But because it changes the economics of certain tasks. Things that weren’t worth doing because the effort exceeded the benefit are now tractable.
Writing tests for legacy code without tests? Used to be a multi-day investment that teams avoided. Now it’s a few hours of guided generation and review. Documenting that internal tool nobody wants to maintain? Tedious but now practical. Exploring a new codebase before making changes? Used to take days of reading. Now I have a knowledgeable guide.
The shift isn’t “AI writes code for you”. It’s “the effort required for certain tasks dropped significantly”. That changes which projects are viable and which maintenance tasks actually get done.
Human in the Loop
Every piece of generated code gets reviewed. Every suggestion gets evaluated against what I know about the system. I modify a fair amount of what Claude produces – sometimes because it’s wrong, more often because I want it slightly different.
This isn’t a complaint about Claude. It’s the nature of working with any tool that doesn’t have your full context. Claude doesn’t know that this codebase has a quirk where that function behaves differently than its signature suggests. It doesn’t know that the team agreed to avoid that pattern last month. It doesn’t know that this feature will be deprecated next quarter.
The value is in the collaboration. Claude moves fast on the mechanical work. I provide context, make judgement calls, and catch issues that require institutional knowledge. Neither of us would be as effective alone.
Practical Advice
If you’re considering integrating AI tools into your workflow:
Invest in planning mode. It’s tempting to skip straight to implementation, but the upfront investment in planning pays dividends.
Build skills for your common workflows. The time spent encoding your standards into reusable skills comes back quickly.
Use tasks for complex work. Breaking work into tracked tasks provides structure and makes progress visible.
Learn the tool’s patterns. Claude has tendencies – certain stylistic preferences, default approaches. Knowing these helps you guide it effectively.
Stay engaged. The fundamentals still matter. Understanding algorithms, system design, debugging techniques – these inform how you evaluate and direct what the AI produces.
Conclusion
Claude Code has genuinely improved my productivity. The combination of planning mode, skills, and tasks has created a workflow that’s more than the sum of its parts. It hasn’t replaced thinking, judgement, or experience. It’s amplified them.
This is the most significant shift in how I work since I started using version control. Not because the AI is doing my job – it isn’t. Because it’s handling the mechanical work well enough that I can focus on the parts that actually require experience and judgement.
It’s a tool. A good one. And like any good tool, it rewards learning how to use it well.
Building Swanson: A RAG-Enhanced Recommendation System for whatisonthe.tv
Background reading: This article builds on Building whatisonthe.tv with FastAPI, which covers the core architecture, async patterns, and caching strategies used in the application.
Since October last year, I’ve been logging everything I watch on whatisonthe.tv. The check-in feature has been working well, and I now have a solid history of viewing habits. But there was a problem I kept running into: finding something to watch with my mum.
The Problem
When I’m at my mum’s house, half the battle is finding something we can both enjoy. She’s not into anything too dark or violent. I’m not keen on anything too slow. Our overlap is narrower than you’d think, and scrolling through streaming services together rarely ends well.
I had the data. Months of check-ins, tagged with who I watched with, when, and what we thought. What I didn’t have was a way to use that data intelligently.
So I built Swanson.
What is Swanson?
Swanson is a RAG-enhanced chatbot that sits inside whatisonthe.tv. Named after the character from Parks and Recreation, it gives straightforward recommendations based on your viewing history. No fluff, no endless scrolling. Just practical suggestions with brief explanations.
Your browser does not support the video tag.
The “RAG” part (Retrieval-Augmented Generation) is the key. Rather than asking a generic LLM for recommendations, we first retrieve the user’s viewing history from the database, build a taste profile, and inject that context into the prompt. The LLM then generates recommendations grounded in actual data rather than making things up.
It’s not a true AI agent. It doesn’t have tool use, multi-step reasoning, or autonomous decision-making. It’s a context-aware LLM wrapper with a feedback loop. But that’s exactly what I needed.
The Architecture
The implementation spans three layers: an LLM abstraction, an API endpoint with Server-Sent Events (SSE), and a SvelteKit frontend that streams the response in real-time.
LLM Provider Abstraction
I wanted the ability to swap providers without changing application code. The abstraction is simple:
class LLMProvider(ABC):
"""Abstract base class for LLM providers."""
@abstractmethod
async def complete(self, system_prompt: str, user_prompt: str) -> str:
"""Generate a completion from the LLM."""
pass
@abstractmethod
async def stream(self, system_prompt: str, user_prompt: str) -> AsyncIterator[str]:
"""Stream a completion from the LLM, yielding text chunks."""
pass
Currently there are two implementations: AnthropicProvider for Claude and OpenAIProvider for GPT models. I’m using Claude (specifically claude-sonnet-4-20250514) and at the time of writing, I heavily favour it over ChatGPT for this kind of work. The reasoning and tone are better suited to making recommendations.
Swapping providers is a configuration change:
def get_llm_provider() -> LLMProvider:
if settings.llm_provider == "anthropic":
return AnthropicProvider(
api_key=settings.anthropic_api_key,
model=settings.llm_model,
)
elif settings.llm_provider == "openai":
return OpenAIProvider(
api_key=settings.openai_api_key,
model=settings.llm_model,
)
The System Prompt
Swanson’s personality comes from a carefully crafted system prompt:
SYSTEM_PROMPT = """You are Swanson, a straightforward TV and movie recommendation assistant.
Your job is to help users find something to watch based on their viewing history and preferences.
Guidelines:
- Be direct and practical - no fluff
- Give 3-5 specific recommendations with brief explanations
- Focus on WHY each recommendation fits the user's taste
- If the search results are provided, prioritize those but feel free to suggest others
- If the user hasn't watched much, acknowledge that and give broader suggestions
- Keep responses concise
Format your response using markdown:
- Always wrap show/movie titles in double asterisks for bold: **Title Here**
- Use a blank line between paragraphs
- Start each recommendation on a new line with a bullet point
At the end of your response, add a line starting with "TITLES:" followed by a comma-separated list of the exact titles you recommended.
TITLES: The Wire, Breaking Bad, The Sopranos
"""
The TITLES: line is a structured extraction pattern. After the LLM finishes streaming, the frontend parses out the titles and uses the internal search API to fetch metadata, images, and links. This gives us rich recommendation cards rather than just text.
Building the User Context
Before each request, we build a taste profile from the user’s check-in history:
async def get_user_taste_profile(db: AsyncSession, user_id: int) -> dict:
"""Build a taste profile from user's checkin history."""
result = await db.execute(
select(Checkin)
.where(Checkin.user_id == user_id)
.options(
selectinload(Checkin.content).selectinload(Content.genres)
)
.order_by(Checkin.watched_at.desc())
.limit(50)
)
checkins = result.scalars().all()
# Aggregate: genre counts, content types, recent titles
# Returns structured data for the prompt
This profile gets injected into the user prompt along with any current search results and feedback from previous recommendations:
## User's Viewing Profile
Total watches logged: 47
Top genres: Drama, Comedy, Crime, Thriller, Mystery
Recently watched: Slow Horses, Shrinking, The Bear, Silo, Severance
Content types: series (32), movies (15)
## User Feedback on Previous Recommendations
Very interested in: The Diplomat
Somewhat interested in: For All Mankind
Not interested in: Foundation
## Already Recommended This Session
Do NOT suggest these again: The Diplomat, For All Mankind, Foundation
## User's Question
Something like Slow Horses but funnier
Server-Sent Events for Streaming
Rather than waiting for the full response, Swanson streams tokens as they’re generated. The FastAPI endpoint uses StreamingResponse with SSE format:
@router.post("/recommend/stream")
async def get_recommendation_stream(
request: RecommendRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Stream an AI-powered recommendation."""
taste_profile = await get_user_taste_profile(db, current_user.id)
user_prompt = build_user_prompt(
request.prompt,
taste_profile,
[r.model_dump() for r in request.search_results],
[f.model_dump() for f in request.feedback],
request.previous_recommendations,
)
llm = get_llm()
async def generate():
try:
async for chunk in llm.stream(SYSTEM_PROMPT, user_prompt):
yield f"data: {json.dumps(chunk)}nn"
yield "data: [DONE]nn"
except Exception as e:
yield f"data: [ERROR] {str(e)}nn"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
)
The Anthropic SDK’s messages.stream() method yields text chunks as they’re generated. We wrap each chunk in SSE format (data: ...nn) and the frontend picks them up in real-time.
Frontend Streaming
On the SvelteKit side, we consume the SSE stream and render it progressively:
async function streamResponse(prompt: string) {
swansonMessages.update(msgs => [...msgs, { role: 'user', content: prompt }]);
swansonLoading.set(true);
swansonStreamingText.set('');
const feedback = collectFeedback();
const previousRecs = collectPreviousRecommendations();
try {
let accumulated = '';
for await (const chunk of api.swanson.stream({
prompt,
search_results: searchResults,
feedback: feedback.length > 0 ? feedback : undefined,
previous_recommendations: previousRecs.length > 0 ? previousRecs : undefined
})) {
accumulated += chunk;
swansonStreamingText.set(accumulated);
}
// Parse titles and search for recommendations
const { cleanContent, titles } = parseTitles(accumulated);
let recommendations: SearchResult[] = [];
if (titles.length > 0) {
recommendations = await searchForTitles(titles);
}
swansonMessages.update(msgs => [...msgs, {
role: 'swanson',
content: cleanContent,
recommendations
}]);
} finally {
swansonLoading.set(false);
}
}
As tokens arrive, swansonStreamingText updates and the UI re-renders. The user sees the recommendation being written in real-time, which feels much more responsive than waiting for a complete response.
The Workflow
The typical flow looks like this:
- Filter check-ins: Select content watched with my mum in a given time frame (say, the last three months)
- Open Swanson: Click the Swanson button to open the recommendation modal
- Ask a question: “Something we’d both enjoy, maybe a bit lighter than Slow Horses”
- Stream the response: Swanson processes the viewing history and streams recommendations
- Review the cards: Each recommendation appears with artwork, type, and year from TheTVDB
- Provide feedback: Mark recommendations as “very interested”, “interested”, or “not interested”
- Refine: Ask again, and Swanson incorporates the feedback
On desktop, Swanson returns five recommendations. On mobile, it’s three. This keeps the interface clean and prevents decision fatigue.
The feedback loop is the key feature. If I mark “The Diplomat” as something I’m very interested in and “Foundation” as something I’m not interested in, the next recommendation round knows to lean towards political thrillers and away from hard sci-fi. This iterative refinement is far more useful than a single static recommendation.
Technical Decisions
Why SSE instead of WebSockets?
SSE is simpler for this use case. We’re only streaming in one direction (server to client), and SSE has native browser support with automatic reconnection. WebSockets would be overkill.
Why parse titles from the response?
I considered having the LLM return structured JSON, but that complicates streaming. With the TITLES: pattern, we can stream the natural language response and extract structure at the end. The trade-off is that parsing is fragile, but in practice it works reliably.
Why Claude over GPT?
Personal preference. Claude’s responses feel more thoughtful for recommendation tasks, and the streaming API is clean. The abstraction layer means I can switch if that changes.
Cost
I’ve put $10 of API credit into Swanson. At current Claude Sonnet pricing, that should last a while. Each recommendation request uses roughly 1,500-2,000 tokens (mostly from the viewing history context), which works out to a fraction of a cent per request.
I’ll keep an eye on usage, but for a personal tool used a few times a week, the economics work.
What’s Next
This is version one. Some things I’m considering:
- Caching similar queries: If I ask the same question twice within a session, skip the LLM call
- Better feedback persistence: Currently feedback only persists within a session. Cross-session learning would be valuable
- Collaborative filtering: Compare my viewing history against other users (if I ever open this up to others)
- Watch party mode: Recommendations specifically optimised for group viewing
For now, though, it solves the immediate problem. When I’m at my mum’s house and we need something to watch, I can pull out my phone, filter to our shared viewing history, and get tailored suggestions in seconds.
The source code is part of whatisonthe.tv on GitHub. Swanson lives in the backend/app/api/swanson.py and frontend/src/routes/swanson/ directories.
Custom Agents for Claude Code
Claude Code ships with a general-purpose agent, but it doesn’t know your stack, your conventions, or your workflows. Custom agents fix that.
The Setup
Agents live in ~/.claude/agents/. Each is a markdown file with YAML frontmatter:
---
name: code-reviewer
description: "Senior-level code review with architecture focus"
model: sonnet
tools:
- Bash
- Glob
- Grep
- Read
---
You are a principal engineer conducting code reviews...
The tools list constrains what the agent can do. A reviewer doesn’t need Edit or Write. It’s read-only by design.
What Makes a Good Agent Prompt
1. Explicit process steps
Don’t assume the model knows your workflow. Spell it out:
## IMPORTANT: Process
1. **Gather context** - Run `git diff`, read modified files fully
2. **Understand intent** - What problem is this solving?
3. **Analyse systematically** - Work through each category
4. **Prioritise** - Must fix → Should fix → Consider
The model follows instructions better when they’re numbered and imperative.
2. What NOT to do
Negative constraints prevent common failure modes:
## What NOT to Do
- Don't nitpick style (linter's job)
- Don't block on personal preference
- Don't review code you haven't read
Without these, the agent will happily suggest renaming variables or adding docstrings you didn’t ask for.
3. Output format with examples
Show the exact structure you want. The model will match it:
## Output Format
### Must Address
- **Location**: file:line
- **Issue**: What's wrong
- **Why**: Impact/risk
- **Fix**: How to resolve
Include a worked example. One good example beats three paragraphs of explanation.
Parallel vs Chained
Parallel: independent tasks that don’t share state:
Run these agents in parallel:
- code-reviewer: review the PR
- security-auditor: check for vulnerabilities
Both run concurrently. Results come back together.
Chained: when output feeds into the next step:
1. Use api-designer to design the endpoint
2. Then use test-writer to create tests for it
Sequential. The test-writer sees the api-designer’s output.
Use parallel when you can. It’s faster and the tasks are cleaner.
Trade-offs
Advantages:
- Consistent output format across runs
- Domain knowledge baked in (your stack, your patterns)
- Tool restrictions prevent accidents (read-only agents can’t break things)
- Reusable across projects
Disadvantages:
- Maintenance overhead (agents drift as your practices change)
- Prompt engineering is trial and error
- Model updates can change behaviour unexpectedly
My Setup
Ten agents covering the common workflows: code-reviewer, security-auditor, debugger, test-writer, refactorer, api-designer, database-expert, devops, documentation, dependabot.
The dependabot one saves the most time. It processes all open Dependabot PRs, rebases them, runs tests, and reports which are ready to merge. What used to be 20 minutes of tab-switching is now one command.
Worth the setup cost.
Adding OAuth to the Authentication Service
The Next Step
In Extracting Authentication to a Microservice, I described building a dedicated auth service with JWT-based authentication and RBAC. It worked well for email/password authentication, but we needed more.
Enterprise clients wanted SSO via Keycloak. Consumer users expected Google sign-in. Time to add OAuth 2.0.
The Providers
Keycloak for enterprise SSO. Self-hosted, open source, speaks OIDC fluently. Clients can connect their existing identity provider and we integrate via Keycloak as an intermediary.
Google for consumer sign-in. Reduces friction for users who don’t want another password.
Two different providers, but the OAuth flow is fundamentally the same.
FastAPI Library Options
I evaluated four libraries:
Authlib
The most comprehensive OAuth library for Python. Supports OAuth 1.0, OAuth 2.0, and OIDC. Framework-agnostic with FastAPI integration.
Pros: Complete spec implementation, well-documented, handles PKCE and JWT validation.
Cons: Large library with features you might not need. Some learning curve.
httpx-oauth
Lightweight library with async support. Built for modern Python with httpx.
Pros: Async-native, simple API, minimal dependencies.
Cons: Less comprehensive. Fewer providers supported. Need to implement some OIDC features yourself.
fastapi-users
Full authentication solution including OAuth. Handles users, passwords, sessions, and OAuth in one package.
Pros: Everything in one place, database models included.
Cons: Opinionated about database models. Less flexible with existing infrastructure.
aioauth
Async OAuth 2.0 provider library. Implements the full OAuth 2.0 spec with async/await throughout.
Pros: Fully async, clean implementation, gives you control over storage and user models, well-typed.
Cons: Lower-level than some alternatives. You implement the storage layer yourself.
The Decision
I chose aioauth.
Async from the ground up. FastAPI is async. The auth service is async. aioauth doesn’t bolt async onto a sync library. It’s async throughout.
Control over storage. We already had user models and database infrastructure. aioauth doesn’t impose its own models. You implement a storage interface and it handles the OAuth protocol.
Full spec implementation. aioauth implements OAuth 2.0 properly. Authorization code flow, PKCE, token refresh, token revocation. Not a simplified subset.
Clean separation. aioauth handles the OAuth protocol. We handle users, sessions, and JWTs. Clear boundaries.
The trade-off is that aioauth is lower-level. You write more code. But that code is yours to control, and it integrates cleanly with existing infrastructure rather than fighting it.
Implementation
The Storage Interface
aioauth requires you to implement a storage class. This is where your database integration lives:
class AuthStorage(BaseStorage):
async def get_client(self, client_id: str) -> Optional[Client]:
# Fetch OAuth client from database
async def create_authorization_code(self, code: AuthorizationCode) -> None:
# Store auth code
async def get_authorization_code(self, code: str) -> Optional[AuthorizationCode]:
# Retrieve and validate auth code
This felt like boilerplate initially, but it meant the OAuth layer integrated naturally with our existing SQLAlchemy models and async database sessions.
Keycloak Configuration
Keycloak exposes a .well-known/openid-configuration endpoint. We fetch metadata from there and configure aioauth accordingly. When Keycloak gets upgraded, discovery handles any endpoint changes.
Google Configuration
Same pattern, different metadata URL. Both providers use the same code paths once configured.
The Flow
- User clicks “Sign in with Google” or “Sign in with your company account”
- Generate state parameter and PKCE verifier
- Redirect to provider’s authorisation endpoint
- User authenticates with the provider
- Provider redirects back with authorisation code
- Exchange code for tokens via aioauth
- Validate ID token, extract user claims
- Find or create user in our database
- Issue our own JWT
What Worked Well
Async throughout. No blocking calls hiding in the OAuth flow. Database queries, HTTP requests to providers, token validation. All async.
Storage flexibility. Adding Redis-backed token storage for revocation was straightforward. Just implement the interface methods.
Testability. The storage interface makes testing easy. Mock the storage, test the OAuth logic in isolation.
What I’d Do Differently
Earlier load testing. The async implementation handles concurrency well, but we found connection pool limits under load. Should have tested earlier.
Better error messages. OAuth failures are opaque. We added detailed logging for each step to diagnose issues faster.
Conclusion
aioauth was the right choice for a FastAPI service with existing infrastructure. It handles the OAuth protocol correctly and gets out of the way for everything else.
The key decisions:
- aioauth over higher-level libraries (needed async, had existing models)
- Implement storage interface (clean integration with existing database)
- Metadata discovery for providers (reduces configuration)
If you’re starting fresh and want batteries included, look at fastapi-users. If you have existing infrastructure and want control, aioauth is worth the extra setup.
Extracting Authentication to a Microservice
The Problem
Authentication isn’t a feature you bolt on – it’s foundational infrastructure that deserves its own service boundary.
I recently architected and built a dedicated authentication and authorisation service, extracting it from a monolithic application where auth logic was tangled with business logic. Other services and applications now consume this auth service, creating a centralised identity provider that serves multiple consumers.
This is the story of the patterns I used, the trade-offs I made, and why I started with authentication as the foundation and am fanning out to related concerns (user profiles, preferences, permissions) as needed.
Why Extract Authentication?
Separation of Concerns
Authentication is cross-cutting. Multiple applications needed it – the original monolith, new microservices, mobile apps, third-party integrations. Rather than duplicating auth logic across services, we extracted it into a dedicated identity provider.
This service handles only authentication and authorisation. User profiles (names, email addresses, contact information) will eventually live in a separate user service. User preferences (UI settings, notification preferences) will live elsewhere. We’re deliberately starting narrow – focusing solely on “who you are” and “what you can do” – and expanding the domain model as requirements emerge.
By extracting auth into its own service, I:
- Centralised identity management across all consuming applications
- Made authorisation decisions consistent (same roles and permissions everywhere)
- Eliminated duplication of auth logic across services
- Created a single source of truth for authentication state
Multiple Consumers, Unified Identity
The auth service serves:
- The original monolith (being gradually decomposed)
- New microservices being built alongside the monolith
- Mobile applications requiring OAuth2 flows
- Third-party integrations needing API authentication
- Internal tools and admin dashboards
Each consumer gets the same authentication guarantees, the same permission model, and the same security posture. When a user’s access is revoked, it’s revoked everywhere. When permissions change, they change consistently across all applications.
Independent Scaling
Authentication patterns are different from business logic patterns. Auth services handle:
- High-frequency token validation (every API request across all services)
- Burst traffic during login periods (morning login rush, post-deployment re-authentication)
- Read-heavy operations (thousands of token validations per second vs occasional user creation)
Separating auth lets the team scale it independently based on its actual load characteristics, not the characteristics of the broader application. I designed it to optimise caching, connection pooling, and database read replicas specifically for auth workload patterns.
Security Boundary
Authentication credentials deserve isolation. By moving auth to its own service, we:
- Limited the attack surface (fewer services handling passwords)
- Simplified security audits (smaller codebase to review)
- Made credential rotation easier (one service to update)
- Improved secrets management (auth secrets stay in auth service)
Is “Microservice” the Right Term?
Probably. The term is overloaded and often misused, but this fits the definition:
- Single responsibility (authentication and authorisation)
- Independent deployment
- Own database (not shared with other services)
- Communicated via HTTP APIs
- Stateless (tokens, not sessions)
We didn’t build a microservice for the sake of it. We extracted auth because it made architectural sense – the boundaries were clear, the responsibilities distinct, and the benefits tangible.
The Migration: SQLite to Production Database
Starting with SQLite
The initial prototype used SQLite. This was deliberate:
- Fast iteration during development
- No external dependencies (database is a file)
- Simple schema migrations (just delete the file and rebuild)
- Sufficient for proving the concept
Migrating to PostgreSQL
Once the patterns were validated, we migrated to PostgreSQL:
- Proper concurrency support (SQLite’s locking is too coarse for production)
- ACID transactions for sensitive auth operations
- Foreign key constraints enforced at the database level
- Better query performance for complex permission lookups
The migration was straightforward because we’d abstracted the database layer from the start. The ORM handled most differences, and the handful of SQLite-specific quirks (like INTEGER PRIMARY KEY vs SERIAL) were isolated to schema definitions.
Key Lesson: Start Simple, Migrate Later
SQLite let us iterate fast without infrastructure overhead. PostgreSQL gave us production-grade reliability. Starting with SQLite and migrating later was the right trade-off for this project.
In-Memory Testing
The Pattern
We run the entire test suite against an in-memory database. No fixtures, no seed data, no teardown scripts. Every test:
- Spins up a fresh in-memory database
- Runs migrations to create schema
- Executes the test
- Discards the database
Why This Works
- Fast: In-memory databases are orders of magnitude faster than disk-based
- Isolated: Each test gets a clean slate, no shared state between tests
- Reliable: No “test pollution” where one test’s data affects another
- Portable: No external dependencies, tests run anywhere
The Trade-off
In-memory testing doesn’t catch database-specific issues (locking behaviour, transaction isolation, query performance). We mitigate this with:
- Integration tests against a real PostgreSQL instance (run less frequently)
- Staging environment that mirrors production database configuration
- Database-specific features (like PostgreSQL’s
JSONBor full-text search) tested separately
Key Lesson: Fast Tests Beat Comprehensive Tests
Fast tests get run. Slow tests get skipped. We optimised for fast feedback loops, accepting that some edge cases would only surface in integration tests or staging.
Architectural Patterns
Stateless Token-Based Authentication
We use JWTs (JSON Web Tokens) for authentication. This means:
- No session storage (stateless)
- Tokens contain user identity and permissions (self-contained)
- Other services validate tokens without calling the auth service (distributed validation)
Trade-off: Token revocation is harder (tokens are valid until they expire). We handle this with short expiry times (15 minutes) and refresh tokens for extending sessions.
Role-Based Access Control (RBAC)
Permissions are modelled as roles, with roles containing granular permissions. Users are assigned roles, and services check permissions rather than roles directly.
This separates “what a user can do” (permissions) from “who the user is” (role membership). Adding new permissions doesn’t require changing user records – we modify role definitions instead.
UUID Primary Keys
All entities use UUIDs as primary keys rather than sequential integers. This adds complexity (UUIDs are 128-bit vs 64-bit integers, indexing is slightly slower, more storage overhead), but the benefits outweigh the costs:
- Distributed generation: Multiple services can generate IDs without coordination or collision
- No information leakage: Sequential IDs reveal record counts and creation order
- Merge-safe: Migrating data between environments doesn’t require ID remapping
- Future-proof: Prepares for potential database sharding or multi-region deployment
The performance overhead is negligible for auth workloads, and the architectural flexibility is valuable.
Layered Architecture
The service is structured in layers:
- API Layer: HTTP endpoints, request validation, response formatting
- Service Layer: Business logic (authentication, token generation, permission checks)
- Repository Layer: Database access, query construction, transaction management
Each layer has a single responsibility. Testing is straightforward – mock the layer below, test the layer above.
What We Got Right
Database Abstraction from Day One
I abstracted database access behind a repository pattern from the start. This made the SQLite → PostgreSQL migration trivial and enabled in-memory testing without touching business logic.
Test Isolation
Every test runs in isolation with its own database. This eliminated flaky tests caused by shared state and made the test suite parallelisable.
Observability from the Start
I’ve been bitten too many times by insufficient observability. This time, metrics, logging, and tracing were built in from day one, not bolted on after deployment.
Observability is paramount when you migrate to a service-based architecture. In a monolith, you can debug by stepping through code or tailing a single log file. In a distributed system, a single request spans multiple services, and failures cascade in non-obvious ways.
The auth service includes:
- Structured logging: Every authentication attempt, permission check, and token operation is logged with correlation IDs
- Metrics: Authentication success/failure rates, token validation latency, permission check duration, database connection pool usage
- Distributed tracing: Request spans across services, showing exactly where time is spent (database query? external API call? business logic?)
- Health checks: Liveness and readiness endpoints for orchestration platforms
When authentication fails, I can trace the request through the entire system. When latency spikes, I know immediately which component is slow. When database connections are exhausted, alerts fire before users notice.
This isn’t over-engineering – it’s table stakes for production microservices.
Clear Service Boundaries
Authentication is a well-understood domain with clear inputs and outputs:
- Input: Credentials or token
- Output: Authenticated user or rejection
The boundaries were obvious, making the microservice extraction clean.
What We’d Do Differently
Token Revocation
Short-lived JWTs work for most cases, but immediate revocation (e.g., user logs out, admin disables account) requires additional infrastructure. We’d implement a token blacklist or switch to opaque tokens for critical use cases.
Database Connection Pooling
We initially underestimated connection pool sizing for the auth service. High-frequency token validation meant connection exhaustion under load. Proper pooling configuration (with sensible limits and timeouts) should have been part of the initial design.
Lessons Learned
Microservices Aren’t the Default
Most systems should be monoliths. Extract services only when boundaries are clear and benefits are tangible. Authentication qualified because the domain is well-understood, the boundaries are obvious, and the scaling characteristics differ from the main application.
Start Simple, Migrate Deliberately
SQLite was the right choice for prototyping. PostgreSQL was the right choice for production. Starting with the simpler option and migrating later was faster than trying to get production infrastructure right from day one.
Fast Tests Enable Confidence
In-memory tests run in seconds. This means we run them constantly. Confidence comes from running tests frequently, not from having perfect test coverage.
Abstractions Pay Dividends
The repository pattern felt like over-engineering during the SQLite prototype. It paid for itself the moment we migrated databases and when we implemented in-memory testing. Good abstractions are invisible until you need them.
Conclusion
Building an authentication service taught me that microservices work when boundaries are clear, responsibilities are single-purpose, and scaling characteristics justify separation.
The patterns I used – SQLite for prototyping, in-memory testing, layered architecture, JWT-based stateless auth – aren’t novel. They’re battle-tested approaches that work because they’re simple, composable, and well-understood.
Choose boring technology, optimise for fast feedback loops, and only introduce complexity when the benefits are obvious.
Building an AI Agent That Earns Yield on Its Own USDC
AI agents are increasingly earning and holding digital assets. Whether it’s payment for services, transaction fees, or operational reserves, USDC has become the standard treasury asset for agents on Ethereum L2s. But there’s a problem: idle USDC earns 0% while centralized entities profit from the float.
What if your AI agent could earn 7-13% APY on its USDC while keeping most of it liquid for operations? That’s exactly what Clicks Protocol enables.
The Idle Treasury Problem
Most AI agents follow this pattern:
- Earn USDC through services or payments
- Hold USDC for future transactions or as reserves
- The USDC sits idle, earning nothing
- Protocols like Tether/Circle earn billions on this idle float
Clicks Protocol solves this with a simple premise: split agent USDC into 80% liquid (for operations) and 20% earning yield (via DeFi). The agent maintains full control, and the protocol only takes a 2% fee on yield earned.
Getting Started with Clicks SDK
First, install the SDK:
npm install @clicks-protocol/sdk
Then, in your agent code:
import { ClicksClient } from ‘@clicks-protocol/sdk’;
const clicks = new ClicksClient();
const agentAddress = ‘0xYourAgentAddressHere’;
async function setupAgentYield() {
const tx = await clicks.quickStart(1000, agentAddress);
console.log(‘Yield setup complete:’, tx.hash);
const info = await clicks.getAgentInfo(agentAddress);
console.log(‘Agent status:’, info);
}
The quickStart() method handles agent registration, USDC approval, 80/20 split execution, and yield strategy activation.
Handling Incoming Payments
For agents that regularly receive USDC, automate the yield process:
async function handleIncomingPayment(amount, fromAddress) {
const yieldTx = await clicks.receivePayment(amount, agentAddress);
console.log(‘Yield optimized:’, yieldTx.hash);
// 80% remains liquid, 20% starts earning yield
}
Checking Balances and Yield
async function checkTreasury() {
const info = await clicks.getAgentInfo(agentAddress);
console.log(‘Liquid USDC:’, info.liquidBalance);
console.log(‘Yield USDC:’, info.yieldBalance);
console.log(‘Total earned:’, info.totalYieldEarned);
console.log(‘Current APY:’, info.currentAPY + ‘%’);
}
MCP Server Integration
For agents using Model Context Protocol:
npm install @clicks-protocol/mcp-server
Your agent gains tools like clicks_quick_start, clicks_receive_payment, and clicks_get_yield_info. Ideal for AI agents that shouldn’t manage private keys directly.
x402 Payment Protocol Compatibility
If your agent uses Coinbase’s x402 payment protocol, Clicks is particularly valuable. 80% remains liquid for x402 payments, 20% earns yield, both on Base chain with no bridging needed.
Security
Clicks Protocol is non-custodial, built on audited contracts (Aave, Morpho), all contracts verified on Basescan, and charges fees only on yield, never on principal. 5 contracts, 58/58 tests passing, MIT licensed.
Conclusion
Idle agent treasuries represent one of the largest untapped opportunities in crypto. With simple SDK integration, MCP server support, and x402 compatibility, Clicks Protocol is the first yield protocol built specifically for the AI agent economy.
clicksprotocol.xyz (https://clicksprotocol.xyz/)
GitHub (https://github.com/clicks-protocol)
Docs (https://clicksprotocol.xyz/docs/getting-started)
