Skip to main content
← Back to Blog
Focus Copilot — an ADHD focus assistant app

Stack, Layer by Layer — What and Why Behind Focus Copilot

Focus Copilot (GitHub) is an ADHD and focus assistant: it turns a vague intention into a plan, stays with you while you work, and talks you down when anxiety gets in the way. I built it as a learning project to get hands-on with a stack I hadn't combined before, not as a commercial product. The interesting part isn't the app itself, it's everywhere a choice in the stack left a visible scar in the code: a timeout, a step cap, a comment explaining why something slower is actually safer.

Next.js 16: Where the Server Boundary Actually Lives

Server components are the default; 'use client' only shows up for real interactivity, like the chat interface or a countdown timer. The actual security boundary isn't React itself. It's that the model-provider API key and the Postgres connection string live in code that only ever runs on the server and never gets bundled for the browser. middleware.js protects everything under /app/*, but cheaply rather than thoroughly, a tradeoff covered more in the auth section below.

JavaScript With @ts-check Instead of TypeScript

Every file opens with // @ts-check, a line that VS Code and tsc both recognize as a directive: it infers types from the JS itself, reads JSDoc tags like @param and @returns as real annotations, and flags mismatches in the editor and in CI. The payoff is concrete. The task schema names a field estimateMinutes; write task.estimatedMinutes anywhere by mistake and plain JavaScript stays silent about it until something reads undefined at runtime. With @ts-check on, the editor underlines the typo immediately.

The gap: nothing in package.json runs tsc --noEmit, so unless that command is wired into CI, the protection only exists in the editor and is easy to skip with a quick terminal edit. The broader tradeoff is real too: JSDoc gets verbose fast for generics and unions. Between Zod at runtime, Drizzle's inferred database types, and JSDoc for plain function signatures, that tradeoff covers what this project needs without committing to a TypeScript build.

Vercel AI SDK and a Free-Tier Gemini Model

All three agents go through the same pipe: the Vercel AI SDK's ai package with @ai-sdk/google, routed to gemini-3.1-flash-lite. The planner calls generateObject for a single structured plan; the session and calm agents call streamText for ongoing conversation.

Gemini's free tier is genuinely free, which matters for a project with no revenue, but it throttles hard. In practice I hit limits after roughly twenty requests a day, and that quota forced the architecture: the session agent's three-step tool-loop cap and 20-second hard abort exist because Vercel kills a function at 25 seconds, and a throttled API makes a hang more likely. Failing fast on purpose beats timing out silently.

Two gaps are worth naming. The telemetry pricing table currently records Gemini usage as $0, accurate today but wrong the moment paid usage is enabled. And because nothing routes between models or providers, a Gemini outage takes all three agents down at once: that's the real incident behind the blunt “Couldn't reach the assistant” message users have actually seen. A fallback as simple as switching providers after two consecutive 429s would close that gap, since the AI SDK already abstracts the provider. Every LLM response that needs to become structured data also gets checked against a Zod schema before it touches the database layer, since a model can hallucinate a field or return malformed JSON.

Drizzle ORM on Plain Postgres

The database is plain Postgres on Neon's serverless driver, with Drizzle as the schema and query layer. The schema lives in one file, checked into git and reviewed like any other change. Migrations are generated diffs you read before running them: adding a nullable column becomes a migration file in git rather than a dashboard click nobody reviews. I covered the full comparison with Supabase's client in a separate post, since it deserved more room than a paragraph here.

One File Owns Every Query

All SQL lives in a single queries.js; no route or component touches the database client directly. Every query that reads a user's row filters on that user's ID in the WHERE clause:

and(eq(tasks.id, taskId), eq(tasks.userId, userId))

That filter is the actual authorization model. There is no middleware and no Postgres row-level security policy underneath it; a user sees a row because the query that fetched it was written to only return rows that belong to them. That's also its biggest weakness: nothing technical catches a future query that forgets the filter, which is exactly the kind of gap a regression test or a lint rule should close instead of relying on review discipline forever.

Functions follow one shape: a JSDoc block on every export, a payload object for anything with more than two or three arguments, and return types pulled straight from Drizzle's inference instead of hand-written. The file is already around 20 functions across five tables, worth splitting by domain once it passes roughly 25 to 30, not before.

Auth.js v5 and JWT Sessions

Focus Copilot uses Auth.js v5, which has carried a beta label through most of its real-world use, with JWT sessions instead of database sessions. The reasoning: a database-session strategy means every auth() call does a Postgres round-trip, and on a serverless connection pool an occasional stale connection can hang rather than fail outright, burning the function's entire 25-second budget just to confirm someone is logged in. A JWT session validates a signed cookie in memory with zero database calls. middleware.js applies the same logic one layer up: Edge middleware can't open a Postgres connection at all, so it does a cheap cookie-presence check and defers real validation to the Node-runtime routes that can. The cost: revoking a session before its JWT expires takes extra plumbing, since there's no server-side session row to delete. Fine for a personal app, but it becomes a real ceiling the moment something like forcing a logout or banning a user mid-session is needed.

API Routes Repeat the Same Three Lines

Every route under src/app/api/ opens with the same block: call auth(), return 401 if there is no session. There is no shared wrapper, so each route repeats it rather than risk someone forgetting it; a requireAuth() helper would remove that risk cheaply. Ownership beyond that point goes through the query layer above, not a second guard: a route passes userId down and queries.js does the filtering.

Routes also pin their runtime explicitly whenever the default would be wrong:

export const runtime = 'nodejs'; export const maxDuration = 30; // Node needed for postgres-js; extra time for streaming

Both lines exist because the default silently breaks something: Edge can't run the postgres-js driver, and the default function duration is too short for a streaming response.

Testing and the One Scheduled Job

Vitest runs against plain Node, not jsdom, since most of what needs unit coverage is schema validation and agent logic, not DOM behavior. Playwright drives real Chromium against npm run dev for everything that needs an actual browser. There is exactly one cron job, firing at 7am UTC and hitting /api/cron/replan to roll forward up to three tasks. Rather than stand up a worker process for something this small and infrequent, the daily replan is just an HTTP endpoint Vercel calls on a timer.

Three Agents, Deliberately Kept Apart

The most opinionated part of the system: there isn't one general-purpose assistant, there are three, each scoped to a job.

  • Planner turns an intention into a plan: one call to generateObject, no tools, schema-checked output.
  • Session runs during actual work: streamText with six tools, capped at three steps. It trusts the task ID it was bound to at session start, not whatever ID the model claims, because the model only ever sees a task's title, never its real database ID.
  • Calm handles grounding and anxiety conversation, deliberately stateless: no database access, no tools, and its own agentType: 'calm' bucket in telemetry so a long anxious conversation never skews the task-flow agents' cost numbers.

Splitting the system this way keeps failure domains small: the planner can only produce a bad plan, the session agent is boxed into six tools and three steps, and the calm agent can't touch the database at all.

Schemas Do Double Duty

Validation lives in one Zod schema file per domain, and each file exports both the schema and a typedef inferred from it:

/** @typedef {z.infer<typeof UserMessageSchema>} UserMessage */

That single line is how a plain JS file gets a real, checked type without anyone hand-writing an interface that could drift from the schema it describes. The schemas shaping LLM tool inputs carry a second job: a bound like .min(2).max(240) on estimate_minutes isn't just rejecting bad input, it's keeping the model's own output sane. These files also explain product rules in their comments, not just syntax, the same habit covered next.

Comments Record Why, Never What

The most consistent habit in the codebase: comments never restate the code below them, they record a constraint or a failure mode that already happened once.

// 'overdue' is intentionally absent — tasks roll forward silently // convertToModelMessages is async in ai v6 — must be awaited, otherwise // throws "messages.some is not a function" // Trust the session-bound task ID, not the model's — any taskId // it supplies is a guess

Each reads like a scar from a bug that already happened, not a note written out of habit. The rule isn't “comment your code,” it's closer to “comment when something burns you, so it can't happen twice.”

Prompts as Data, Skills as Enforced Process

System prompts live in their own files, loaded through a small loadPrompt() helper that hand-rolls a tiny templating syntax over plain string replacement in about 25 lines, no library involved. The point: a prompt-only change should never touch application code, and a 25-line helper is short enough to read in full before trusting it with an LLM call.

A few of the project's hard rules also aren't left as prose to remember. Things like reviewing generated SQL for destructive operations, or checking user-facing copy against a no-shame-language rule, run as automated checks before a change ships. A rule like “no overdue state” stays true in practice because something checks for it, not because everyone remembers to, though the check is only as strong as the discipline to run it: nothing technical stops a direct edit that skips it.

The Pattern Underneath It All

Linting and formatting are unremarkable: plain ESLint, no custom overrides, Prettier for the rest. The one detail worth keeping is a note for whichever AI assistant touches the repo, warning that this Next.js version is newer than its training data, so check the installed docs before assuming an API still works the old way.

Every non-obvious choice in this stack traces back to a specific constraint, a serverless timeout, a free-tier quota, a product ethic about not shaming someone for missing a task, rather than a rule followed for its own sake. A few of them, like single-provider dependence and an authorization model that depends on every query remembering to filter, are fine at this scale and would need real revisiting before this handled paying users or actual money.

Frequently Asked Questions

Why use JWT sessions instead of database sessions in a serverless app?

Because a database-session lookup means a Postgres round-trip on every request, and a serverless connection pool occasionally hands back a stale connection that hangs instead of failing fast. A JWT session validates a signed cookie in memory with zero database calls, so a flaky connection can never eat the whole function timeout just to confirm someone is logged in.

Why does Focus Copilot avoid TypeScript?

It's a deliberate constraint, not a shortcut. Every file starts with a // @ts-check comment, which tells the editor and tsc to type-check that file using JSDoc tags as real annotations. That catches the same class of bug TypeScript catches, without a compile step or a new file extension, though it only works as a real gate if tsc --noEmit is actually wired into CI.

Why run three separate AI agents instead of one general-purpose assistant?

Each agent has a different job and risk profile. The planner turns an intention into a plan with no tools at all. The session agent gets tools but a tight step cap. The calm agent stays stateless on purpose, with no database access and nothing to misuse. Splitting them keeps each one's failure modes small and its cost tracked separately.

What happens when you build on a free-tier LLM API?

The constraints show up directly in the code. Gemini's free tier throttles hard, so the session agent caps its tool-calling loop at 3 steps and aborts after 20 seconds rather than risk a serverless timeout. It also means a single-provider outage takes every AI feature down at once, since nothing currently routes to a fallback model.

How does Focus Copilot authorize access to a user's own data without middleware or row-level security?

Every database query filters on the user's ID directly in the WHERE clause, inside one file that owns all SQL. No route or component touches the database client directly. That per-query filter is the actual authorization model, which is simple and visible but only works if every query remembers to include it.