Hitaansh Jain
← All projects

Header

Software Engineer Intern · May 2026 – Aug 2026

3 major features built · 700+ tests

joinheader.com

Context

Header turns the feeds a user already follows (RSS, YouTube channels, newsletters, Reddit, X) into LLM-written briefings against user-defined goals. There is also a feature called Clear Tabs, which shipped as Link Bankruptcy: paste a pile of saved URLs and get one briefing that triages them. The stack is async Python with FastAPI and PostgreSQL on the backend, a React Native/Expo app on the frontend, and a three-stage LLM pipeline with six providers behind an adapter layer.

I joined as a software engineer intern in May 2026. Of everything on this site, this is the closest to plain production software engineering: a codebase with 100+ API endpoints, dozens of migrations, real users, and code review on every change.

Briefing customization

Users had no control over what a briefing looked like. I built per-goal layout control end to end: drag to reorder sections, toggle each one on or off, set per-section detail, a TLDR pinned to the top, and a computed read time on every briefing.

The schema is one nullable JSONB column on the goals table, with no default and no backfill. NULL means "use the current default," and the default is resolved in code at read time, so improving it later is a code change instead of a data migration across every existing row. The cost is that nothing in the database enforces valid section keys. All of that strictness had to move up into the API-layer validator.

The prompt refactor was the hard part. The old generation prompt hardcoded every section. I broke it into per-section instruction blocks that are order-independent by construction: each block opens with its own heading and carries its own inclusion conditions, and detail level is injected per block rather than as one global directive. An assembler filters to the enabled sections in the user's order and splices the result into the prompt template. The TLDR is deliberately not one of these blocks. It is a second LLM pass over the finished body, which is also why it cannot be reordered: it summarizes the thing it sits above. A normalizer pins it to the top even when a hand-crafted API payload says otherwise, and the drag lock in the UI just mirrors what the backend already enforces.

Rollout went through a default-off feature flag, with the old prompt kept alongside the new one so the flag could switch between them. The flag has since been turned on, and briefings in production now come out in the new section order.

The TLDR needed a second pass of its own. My first fix scaled sentence count with body length, which treated a selection problem as a length problem: the model was picking too much, not writing too long. I replaced it with a word budget and ran a live comparison to check. The control arm reproduced the reported bad output almost exactly, 220 words against the 234 a user had reported, which confirmed the diagnosis instead of assuming it. The budget arm came in at 122 words, with mean sentence length down from 44 to 24. Two runs per arm, so it is a directional check rather than a measurement.

Another piece was format control for Clear Tabs submissions. A submitter picked one of three formats I shipped: a quick link triage, a full briefing, or a custom format built on the same section machinery. Custom meant up to five sections the user wrote themselves, each a title plus plain-English instructions that fed straight into the assembled prompt, with the instructions carrying the length intent instead of a preset detail knob. Validation was strict in both directions: custom sections were required when the format asked for them and rejected when it didn't, with hard caps on count and length so a submission could never smuggle an unbounded prompt into the pipeline.

A history feature shaped by a cascade

Clear Tabs briefings were hidden from the normal dashboard, so a user who lost the direct URL had no way back to a past submission. I added a paginated, authenticated history endpoint and a history screen listing every past briefing with its status and URL count.

The interesting constraint was underneath. Each submission provisions an ephemeral goal, and a cleanup hook later soft-deletes the sources behind it. That hook leaves the goal row alone for one reason: the briefing's foreign key is ON DELETE CASCADE, so deleting the goal would take the briefing with it. So at read time a briefing can reach its goal but not its sources, and the URL count the screen needs is gone. The fix was to denormalize it into the briefing's JSONB stats before generation starts, ahead of both terminal paths, completed and failed. That surfaced a second bug: the completion path rebuilt stats from an explicit allowlist of keys, and any key off that list was silently erased the moment a briefing finished. The new field had to join the allowlist too.

Pagination is bounded limit/offset with a separate count query. Offset pagination drifts when rows are inserted mid-scroll, and for per-user history at this scale keyset pagination would have been over-engineering. I would revisit that choice if the row counts grew. That endpoint now backs two shipped surfaces, the renamed history screen in the app and a list of recent submissions in the Chrome extension, and its count query drives a badge in the UI.

Where it stands

Everything above is live in production: the layout feature, the Clear Tabs history feature, and the TLDR and read-time work. Smaller fixes shipped alongside them, including a repo-wide DNS guard for the test suite, a flaky end-to-end test that had been blocking a production deploy, cross-platform test-infrastructure work, and closing a gap where one content-ingestion path bypassed an egress-validation control that a sibling path already had.

One more feature track I built, larger than anything above: a multi-phase backend lifecycle system covering schema, state machine, write API, email delivery, and agent-facing parity, across five stacked PRs, the last of which shipped its web UI. All five are merged. The details aren't public, so it stays vague here.

The repository is private client work, so there's no public link, but I'm happy to walk through any of this in as much detail as an interview allows.