← Work · Full-stack application · Directed build

TenaCity

A job-search outreach engine that gathers, scores, researches, and drafts, and then stops so a person can read every message before it sends.

Role
Owner and director of the build
Year
2026
Discipline
Full-stack application · Directed build
Status
Live, with sending held by a per-user switch
The TenaCity queue on a laptop screen, showing the totals row for drafts, jobs, contacts, sent, replies, and meetings, and three job listings with their contact and draft counts.

The Problem

Job-search outreach at scale is repetitive work that calls for consistency and patience. A person hunting for a role must find fresh postings daily, judge which ones fit, identify who to contact inside each company, track down a verified email address, and write a message tailored enough to earn a reply. Each step is slow when done by hand and the quality drops as the list grows. When carelessly handled by a machine, the outreach reads as spam and burns the sender's name and domain.

An Inherited Partial Codebase

This project began as a clone of another builder's partial codebase, a Next.js application wired to OpenAI, owned by its original builder, and running on a stack of external credentials. Every reference in it pointed back at that owner. The code needed to be placed under a personal control umbrella in order to trust any of its features. The problems were ownership and verification, as well as, outreach.

More on what ownership required

The handover was built on six conditions. The application had to run locally, draw its language work from Claude, stand on a fresh database, deploy to a live address, answer to a single admin account, and sign that admin in from a personal domain. While they are not outwardly noticeable features, they establish the ownership needed to run this application.

The Pipeline

TenaCity is built on Next.js and backed by Supabase, with Apify for scraping, Claude for the language work, and Resend for email. The system runs as a pipeline of distinct stages, each handing its output to the next. A review queue where the operator approves the results. The machine does the gathering and the drafting. The user finalizes the content and sends it.

Five Stages

  1. Scrape pulls fresh postings for each target title from the last day and folds them into a pool. It is keyed on company, title and location to prevent the same job from appearing twice.
  2. Score applies hard filters, then asks the model for a fit score and a set of research hooks, promoting only the qualifiers and capping how many advance each day.
  3. Find Contacts pulls the employee roster for each qualifying job and asks the model to pick a decision-maker and a second contact strictly from that roster.
  4. Find Emails runs a server-side cascade that finds, verifies and stores an address.
  5. Generate writes a tailored message from a chosen template, letting the model fill only the bracketed variables. It then runs a cleanup pass that further personalizes the message.

An expanded job in the TenaCity queue, showing a contact with their role and email address beside a drafted outreach message with approve and reject controls.

More on the review and sending layers

The review queue presents each job with its contacts and drafts, a character meter for the length-limited channels, and controls to edit, approve, or reject. A sending layer moves approved drafts out through two channels. Approved and verified email goes through Resend, gated by a per-user switch that holds sending off until it is deliberately turned on. Approved LinkedIn messages take an operator-run path, bundled into a prompt for Cowork from a button on the navigation bar, which allows manual send from the user.

A feedback loop records the outcome of each message and turns the results into notes that shape later drafts. The database enforces ownership. Every table has a rule that limits a query to the rows owned by the signed-in account, and a query asking for more returns an empty result. The Prompt templates and pipeline schedule tables hold settings shared across accounts. Any account can read both by design.

The Invariants

Four rules governed the whole build, and each one prevents a failure the system could produce at volume. Each was set before the first line of code and checked at every stage.

  • The model fills variables in a template and never writes a message body from scratch.
  • An email address reaches the database through one verification gate, and the pipeline never guesses one.
  • A job is matched on company, title, and location, never on a posting link.
  • A long scraping run starts asynchronously and reports back through a webhook, and the application stays responsive throughout.
The machine does the gathering and the drafting. The user has the final say.

The TenaCity settings panel showing the control that turns the automatic pipeline on, with its frequency and start time.

More on the reasons behind these four rules

A model given a blank page writes a message that reads as machine-written. A guessed email address damages a sending domain that takes months to rebuild. A posting link changes or expires, and matching it allows the same job enter twice under a new link. A synchronous scrape blocks the application until a long run finishes. Each of these repeats on every job the pipeline touches.

The Build

The build ran in two phases. Phase I made the cloned codebase mine. Phase II refined the working system across seven code changes and several by-hand database steps. Every edit went through Claude Code under Plan Mode and per-edit review.

The Non-Existent Endpoint

Every request failed after the switch to Claude. While the error identified authentication as the issue, the true cause was a wrong address.

The Key In The Public Slot

The database was built by hand from 40 migration files. This work revealed that the master key was published in the slot meant for the public key.

The Function Repeated Twice

A rewrite of the scheduling function left two versions of it in the database. The check that followed found the duplicate.

Lines of code written by hand
0
Migration files applied in order
40
Problems caught at a gate before production
2
More on the build

The Non-Existent Endpoint

The provider swap moved the application off OpenAI and onto Claude. One adapter was removed, another installed, and all six model call sites were repointed to Claude Haiku 4.5. This model was chosen because the tasks are high-volume and light. Every request then failed with a not-found error that looked like an authentication problem. Tracing the actual request revealed that the installed adapter was dropping the address segment that routes to the model. This meant that the correct credentials were landing on a non-existent page. The fix pinned the full endpoint in one shared file and pointed all six call sites at it. A deployment reads the endpoint from the code because the shared file is now the only place it exists.

The Key In The Public Slot

The database was built by hand from the migration files, in strict order. The automated tool was set aside on purpose. The files contain repeated date-prefixes that confuse its ordering. Forty files were applied in order, updating the database from zero tables to twenty.

During this setup, the configuration published to every visitor's browser had a master key in the public key's place. Any site visitor would have had unrestricted access to the database. A cloned project's safety must be checked. Accordingly, I read the published configuration directly and found the key there.

The Twice Repeated Function

Rewriting the scheduling function to accept a new argument created a second copy of it. The database treats a different argument list as a separate function and leaves the old one in place. For a short period two versions existed at once, and the application could have bound to either. I then listed every version of the function, which revealed the duplicate. I removed the old version by naming its full argument list, and a final listing confirmed that only the intended version remained.

The Remaining Phase Work

I renamed the seeded owner account to my email, which brought its working configuration across untouched, and gave it a login and a display name. Deleting a user is irreversible and cascades to everything linked to it, so I confirmed the placeholder account by name and address before removing it. I then published the project to a fresh private repository, deployed it to a live address, and wired it to that address through the database vault. Login email routes through Resend over custom SMTP, and the sign-in template sends a six-digit code, which signed in on the first test.

A second round of work took the scraped-titles cap up to its configured value. I changed the resume parser to merge derived job titles into the existing target list instead of overwriting it, and changed the editor to re-read the saved list so additions appear. I curated the target-title list by hand, cutting 29 leftover seed candidates to a core of 12. An editable start-time control went into the scheduler, showing the local hour beside coordinated universal time. Labeling the picker with the current date resolved a daylight-saving confusion. I recolored the dashboard and chose a new display typeface out of preference.

Scope and Results

TenaCity is a private application. It runs locally, is version-controlled in my repository, and is deployed live. It operates from my database, draws its language work from Claude, and signs me in with a six-digit code from my own domain. The five-stage pipeline and the review queue are built and documented.

The email channel is wired end to end and held off by a per-user switch until it is turned on. The LinkedIn channel is designed and not yet exercised end to end, with storage for approved messages in the database as the remaining piece. This will allow a whole batch to run without moving each message across by hand.

Two problems, the exposed master key and the duplicate scheduling function, were caught by checking what the system actually did at each gate. They did not reach production.

The lasting value of TenaCity is proof that a complex, full-stack application can be taken from a partial clone to a live owned system entirely through directed builds. The system includes a database, a background scheduler, live email sending, and a multi-tenant permission model, and none of it was hand-coded. I scoped each stage, gated each edit, and verified each result.

More on upcoming features and insights

Scheduled Features

  • Multi-source ingest, for postings to arrive from company career boards through their applicant tracking systems.
  • The Fit Brief, a short document for a single job that correlates the company and the role with the skills and background on record, including the mismatches.
  • An application assist. For each job that clears scoring it would tailor the resume to that posting and prepare the application content, then leave the operator to review and submit.

Four Insights

  1. Diagnose before scoping. The not-found error and the clone crash both looked worse than they were until a single variable was tested in isolation.
  2. Build correct code. The pinned endpoint file does not depend on anyone remembering a value.
  3. Sequence the database ahead of the code. Every table existed before any code pointed at it, and the code that followed ran clean.
  4. Confirm the result after a structural change. The listing step that caught the duplicate function existed only to check an assumption, and it prevented a hard-to-trace bug.

Accessibility

TenaCity does not include accessibility work. The review queue is the only interface, and it has not been audited. Several accessibility tasks remain open:

  • Build a keyboard path through the queue from job to contact to draft, with edit, approve, and reject reachable in the order a user works through them
  • Announce the character meter to a screen reader
  • Distinguish approved, rejected, and pending status by more than color
  • Run a screen reader pass and a reflow check at zoom