An applicant tracking system, for applicants.
Every company you apply to runs one. Theirs keeps a record on you, scores what you sent and decides what happens next. This one keeps the record on them — a record of your career, a resume builder and a job-search CRM in one app you host yourself, wired into Claude so you can just talk to it.
- Me — dump everything you know about every job you've had. No length limit, no structure required. Numbers, projects, stories, praise, screw-ups. This is the raw material every resume gets built from. Searching it matches by stem, so looking for "managing engineers" finds the line where you wrote "managed three engineers" — which matters, because the alternative is a resume that says you have no evidence for something you spent two years doing.
- Resumes — tailored documents assembled from that material. Defaults to the Harvard OCS format; four other templates, live preview, real PDF export, and a shareable link for the application forms that want a URL instead of a file.
- Letters — everything you write that is not a resume: cover letters, cold messages,
referral asks, thank-yous, replies. They live under Me, and on the job they were written
for. Each carries a kind, who it went to, whether it has actually been sent, and links to
the application, the person and the resume it went out with — so "what did I already say
to them" is one look rather than a search through your sent mail. The point is
prep_letter: before anything is drafted it gathers the posting, the company research, the last few things on the timeline, the material from Me that actually matches, and up to three letters of the same kind you have already written. That last one does most of the work — two of your own letters describe how you sound better than any instruction about tone. - Pipeline — stages, activity timeline, tasks, and follow-up dates that schedule themselves. A toolbar across the top picks the view — a drag-and-drop board, a sortable table, or a month calendar of everything that has a date on it — plus the cut and a search. Filters combine — all of them, with each other: "applied and interviewing", "interviewing but only the overdue ones", "everything from a referral that has sat a fortnight". Stage chips are the fast lane and the rest live behind one button, counted against what the other filters left. A cut worth keeping gets saved under a name. The table edits in place: stage, follow-up, salary and location are the cells themselves, a Waiting column counts days in the current stage and a Quiet column counts days since anything was logged — the second is the one to chase on — and selecting rows closes a batch out in one action. Screening, interviewing and a final round are one Interviewing stage — which of the three a given employer's process belonged in was always a guess — and how deep you are is a round number you set on the opened job. The board shows none of it; the funnel on Analytics is built out of it, and draws Applied → Round 1 → Round 2 → Offer once you start numbering them. Applications end as accepted or lost, and why they were lost is a tag you own: rejected, ghosted, withdrew, declined their offer, role closed, or your own words. Say which — silence is the most common ending, and filing it as a rejection makes the funnel lie about what went wrong. An application carries tags — several at once, because a job board posting, a referral and a LinkedIn message are often the same job — and a listing is optional: a role you're only chasing through a DM is still worth a card. Opening an application slides it in from the right, so you keep your place on the board. A read-only link shares the board with whoever is helping you — a friend, a coach, a former manager — showing companies, roles, stages and follow-up dates, and never your salaries, notes, contacts or job descriptions. A saved view can have its own link, which is usually the better offer: "everything I have from a referral" is a reasonable thing to send someone and the whole board is not. Everything the view filters out is not in the page at all, and each link has its own address, so revoking one leaves the others alone.
- Offers — what somebody actually put on the table, which is a different fact from
the range the posting advertised and is never mixed with it. Base, bonus, equity,
sign-on, the vesting schedule, the terms in words, and the day you have to answer by —
that last one goes straight into the bell, because it is the one date in this app that
cannot be caught up on tomorrow. Recording a better number writes a new version rather
than overwriting the old one, so the distance between the opening offer and the closing
one stays on file: that gap is the only evidence anybody keeps that negotiating worked.
The board prints the real number in place of the advertised range once there is one, and
compare_offersputs every live one side by side, sorted by what year one actually pays. It will not convert currencies — it says so and asks which rate you want — andoffer_briefinggathers everything on file for one negotiation in a single read: every version, what the posting claimed, the lines in your own timeline where money came up with the note each came from, what you have written about being paid before, and what is still missing. - Two emails, if you want them — nothing leaves this app unless you ask. Two things can:
a summary on Monday of where the search stands, what moved, what is coming and the one
thing worth fixing, and a nudge on a day something is actually due. The nudge sends
nothing on a quiet day, deliberately, so the one that matters never lands in the folder
you stopped reading. Both are off until you turn them on in Settings → Account, no admin
can turn one on for you, and every message says where the switch is. The app runs no
timers: an admin puts a long random string of their own into Digest sweep token under
Admin → Configuration and points their host's scheduler at
/api/digest/followed by it, hourly, and the sweep works out who is due in their own time zone. Leave the token empty and that address is off rather than open. Like every secret there it never reads back, so keep a copy when you set it. - You can take it all with you — Settings → Account has a Download everything button
that saves one JSON file holding every record you own: Me, your resumes, your letters,
the whole pipeline with its timeline, tasks, offers, tags and saved views. Connection
tokens, mail passwords and published links are deliberately left out, so the file is safe
to keep in a backup. Handing it back to
import_everythingrestores it, matched by natural key — a role by employer and title, a company by name — so it adds what is missing and never overwrites what is there. Importing the same file twice does nothing the second time. Spreadsheet exports of the three CRM lists are still where they were, behind the Export button on each list. - A review queue — an assistant that has read a week of your mail finds six things the pipeline does not know. Answering them in the chat works only if you are at the chat, and anything you did not answer is gone. It can queue them instead: they wait on Today, one line each, with the sentence from the email that produced it printed underneath, and you accept or dismiss them whenever you get to it. Five things can be queued — log it, move the stage, add a task, change the follow-up date, add a person — and nothing is written until you say yes. A dismissal is an answer too, so it stays on file and a good assistant will not propose it again.
- Stage checklists — the things you always mean to do and never do. Say once that reaching Applied should add "check the posting is still up" in a week, or that every interview should put "send a thank-you" on tomorrow's list, and it happens on its own from then on. Settings → Pipeline holds them, one short list per stage, each line with a due date counted from the move and a switch to park it. Two rules make it trustworthy: a line fires once per job ever, so dropping back a stage and going forward again does not re-add what you already ticked off, and adding a line never reaches backwards onto jobs already on the board. Deleting a line leaves the tasks it already made alone — deleting a setting should not delete work you are part-way through. A new account has none; there is a starting set of six if you would rather not design your own.
- Nothing is deleted by accident — pressing Delete on a company, a person or an application puts it in an archive rather than destroying it. It leaves every list, board, picker, filter and count immediately, and waits thirty days — an instance setting, or zero to keep everything — before it is deleted for good. Archiving a company takes its applications with it and brings exactly those back on restore, leaving one you binned separately where you put it; the people stay, because somebody is a founder at one company and an advisor at another. Every delete offers an Undo, each list says when that kind has something in the bin, and the two acts that really do destroy something can only reach what is already in there — so nothing in this app can be destroyed in one step.
- Today — everything you owe, and it is the first thing the app opens to. The things you wrote down, grouped by overdue, today, the next seven days, later and no date, each rewordable and re-datable in place and attachable to the role it's about; and beside them the chase list, the follow-ups and pings whose dates have come round, where the verbs are "logged it" and "push it out three days". Ticking a task and logging a chase mean different things, so they don't share a column. On every other screen a bell in the top bar carries one number — follow-ups due, pings due, tasks past their date — and opens them grouped. Nothing in it can be dismissed: a thing leaves that list by being dealt with.
- Analytics — the second tab on that same page, because it is the same subject at a different altitude: one click away, never in the way of the list. The funnel drawn as a flow chart: applications enter on the left, the survivors carry across to the phone screen, the interview, the final round and the offer, and everything that leaves peels off to where it went — rejected, no response, withdrew, offer accepted, still going. A rejection after two interviews leaks out of the interview rung, not the applied one, because progress is measured by how far it actually got. Share chart hands it to you as a PNG or an SVG with no company, role or person named anywhere on it — only the shape — which is what makes it the one thing here that is safe to post. Under it: response rate, what is in flight, the diagnosis of which step is losing people, and recent activity.
- People — companies and the people at them, as records you can visit. (The rail says
People; the address is still
/crm.) A company page holds their website, whatever you have learned about them, and their industries, sizes and locations as labels rather than text boxes — a company is plausibly both fintech and infrastructure, and hiring in two cities — alongside every application and every contact you have there. The website is what puts their logo on the pipeline. Their roles read as job listings, each one a click from the posting it came from. The company list says when you last applied and what's still live, and both lists filter — to the companies where you know someone, to the people whose ping is due — while the search box matches any label. Both lists filter properly now: industry, size, location and tags on companies, tags, company and how long since you logged anything on people, plus the gaps worth fixing in one sitting ("no website", "no email", "filed under nothing"). The Filter button opens on the dimensions rather than on every value at once — pick Industry, then pick from industries — and typing searches across all of them together. Dimensions AND with each other and OR inside themselves, every count is counted against what the other filters left, and every column that has an answer sorts, from its heading or from a Sort control that still works on a phone. Drag the divider between two columns to set their widths, which follow you between devices. Tick rows to tag a batch, put a batch on the chase list, delete a batch, or export just those; each list exports to CSV on its own, honouring whatever you have narrowed it to. Contacts attach to applications straight from the CRM rather than being retyped, and removing one from an application never deletes the person. A person keeps every way you can reach them — LinkedIn, X, Instagram, GitHub, their own site, and anything else you paste — each wearing its own platform's mark, because the address that matters is whichever one they actually answer on. - Tags — one catalogue behind all of it. A tag is a name and a colour you own, and every
list that used to be free text is one: where an application came from, a company's
industry, size and location, how you know a person. Tick to attach, type to create,
recolour from the swatch row, delete outright — deleting says how many things it comes off
before it does it. Names fold case, so
linkedinlands on theLinkedInyou already have rather than minting a twin. - AI connections — every person gets their own URL that turns all of the above into 157 tools any MCP client can call (190 if you're an admin). Claude, Claude Code, ChatGPT, Cursor, VS Code and Windsurf all have one-paste setup built into the app.
- It explains itself — a short tour opens the first time you sign in: what the board is, what Today is for, what Me holds, one picture and one sentence each. Skip it in a click if you have run a search before; it never asks twice. Settings → Account brings it back, and so does asking a connected assistant. Under it, three cards track the only three things a new workspace needs — a job on the board, your history on file, an assistant connected — and tick themselves off from what is actually there.
- Multi-user — invite whoever you like. Each person gets a completely private workspace; admins manage accounts but never see anyone's career history, resumes or applications. Admin lives under Settings → Admin: invitations, accounts, per-workspace usage, a password reset for whoever is locked out, and a log of every administrative change — including changes to how the instance itself is configured, so clearing the Resend key is traceable rather than a mystery three weeks later. Secrets are recorded as having been set, never as their value. Click any name for that account on one page — when they joined, who invited them, whether that invitation email actually left, which assistants they have connected, and everything the instance recorded against their address. It is the page you open when somebody emails asking why they can't get in, and there is no way from it into their career history, resumes or applications.
- You can tell when it breaks — Settings → Admin → Health checks the database, whether
every migration finished, whether the last invite email actually left, and whether Stripe
is still calling the webhook, then lists what has failed in the last thirty days. Ask an
assistant for
admin_healthand you get the same answer without opening a browser. - Your inbox and calendar, on the record — connect your own mail and calendar under Settings → Connections — Google, Microsoft 365, or anything that speaks IMAP and CalDAV, and more than one if recruiters write to more than one — and every contact, company and application shows the real threads and meetings behind it, under the timeline of what you logged. Interviews you accepted in your real calendar land on the pipeline's calendar view. Read-only and live: nothing from any account is copied to the server, and an assistant asked where an application stands reads the recruiter's reply instead of guessing from a stage.
- Sign in how you like — email and password always work, and an instance that adds a Google OAuth client gets a Continue with Google button as well. Google never bypasses an invitation: it signs in people who already have an account or an unexpired invite, and turns everyone else away unless an admin has deliberately opened sign-up.
- Hard to guess at — sign-in attempts are counted per account and per address, and an account stops answering after eight wrong passwords in fifteen minutes. Passwords are scrypt hashes, sessions are httpOnly cookies you can decline to keep past the browser window, and admins cannot reset each other or the owner — the code being public is not the same as the door being open.
"Here's everything I did at Vertex last quarter — file it." "Tailor my resume to this posting." "What do I need to follow up on this week?"
Two ways in. Both are the full product.
Hosted — hired.tools. I run an instance at app.hired.tools and host people on it for a monthly fee: pay, get an invite, connect your assistant, start. Your workspace is private — instance admins manage accounts, never content — and if you stop paying it's suspended, not deleted.
Self-host — free, AGPL, yours forever. One command if you have Docker, or five clicks on Railway if you'd rather never open a terminal. Both below.
The manual is at docs.hired.tools — getting connected, filling in Me, tailoring a resume, running the search, every tool written out, and the deploy guides in longer form than they are here.
On any machine with Docker installed:
curl -fsSLO https://raw.githubusercontent.com/shifulaboratories/Hired/main/docker-compose.yml
docker compose up -d
docker compose logs app # your sign-in details are printed here, onceThat's the whole procedure. It pulls the published image, starts the app and a Postgres it talks to over a private network, applies migrations, creates your owner account and prints the password. Sign in at http://localhost:3000, and read the top of docker-compose.yml for the optional variables (owner email, port, a public URL once you have one).
PDF export works out of the box here — the image carries the browser and fonts the renderer needs, which is the one thing the Railway path below can't give you.
To upgrade:
docker compose pull && docker compose up -dYour data lives in a named volume and survives upgrades and restarts. Back it up with
docker compose exec db pg_dump -U hired hired > backup.sql.
The image is built from this repository on every push and published at
ghcr.io/shifulaboratories/hired. Building it yourself is docker compose build.
One variable, five minutes, no terminal. You don't invent a password, run a migration, or configure anything — the app provisions itself on first boot.
Go to railway.com → New Project → Deploy from GitHub repo → pick this repository. Railway starts building immediately.
The build will succeed, but the app won't start yet — it has nowhere to store anything. You'll see it crash and retry. That's expected; the next two steps fix it, and Railway redeploys on its own.
In the same project, click + Create → Database → Add PostgreSQL.
Railway names the service Postgres. Leave it alone; you never have to configure it.
Click your app service (not the Postgres one) → Variables → New Variable:
| Variable | Value |
|---|---|
DATABASE_URL |
${{Postgres.DATABASE_URL}} |
Type it exactly as shown, ${{ }} included — Railway autocompletes it. It's a reference,
so if the database credentials ever rotate, your app follows automatically.
That's the only variable. Everything else is configured inside the app.
Settings → Networking → Generate Domain. You'll get something like
hired-production.up.railway.app.
On first boot the app creates your owner account and prints the credentials once:
╔══════════════════════════════════════════════════════════════╗
║ Hired is ready — this is your owner account. ║
║ ║
║ Sign in https://hired-production.up.railway.app ║
║ Email owner@localhost ║
║ Password quartz-meadow-falcon-7391 ║
║ ║
║ This password was generated for you and is shown ONCE. ║
╚══════════════════════════════════════════════════════════════╝
Open the Deploy Logs tab on your app service and scroll to the top of the latest deploy. Copy the password, sign in, then change your email and password from Settings.
Database tables are created automatically on every boot — there's no migration step for you to run, now or after any future update.
Optional variables
| Variable | What it does |
|---|---|
ADMIN_EMAIL |
Use this address for the owner account instead of owner@localhost. |
APP_PASSWORD |
Use this as the owner's first password instead of a generated one. |
RESET_OWNER_PASSWORD |
Set to 1 and redeploy to generate a fresh owner password and print it again. Remove the variable afterwards. |
Open Settings in the app. It opens on Connections, because this is the step that makes everything else work. You already have one waiting; click it, pick whichever assistant you use — each is listed with its own logo — and the exact steps appear, with the config already filled in with your URL, ready to copy.
| Client | What you paste |
|---|---|
| Claude (web, desktop, mobile) | The URL, under Settings → Connectors → Add custom connector |
| Claude Code | claude mcp add --transport http --scope user hired "<your URL>" |
| ChatGPT | The URL, as a custom connector |
| Cursor | A three-line block in ~/.cursor/mcp.json |
| VS Code | One code --add-mcp command, or .vscode/mcp.json |
| Windsurf | A three-line block in ~/.codeium/windsurf/mcp_config.json |
| Anything else | A standard streamable-http entry — or mcp-remote if it only speaks stdio |
Open a connection and hit Test: the app calls its own endpoint the way a client would, then tells you how many tools answered — 157, or 190 if you're an admin.
That is a lot of tools, and spelled out in full they're around 49,000 tokens of context before you've said anything. If your client has tool search — Claude Code does, and has it on by default — leave it on: it loads six of them up front, about 1,600 tokens, and looks the rest up when they're needed. On a client without it you're paying the full amount every conversation, which works but is worth knowing.
Connect gives each assistant its own URL. That matters more than it sounds:
- Your laptop dies, or you paste a URL somewhere you shouldn't — New URL or Disconnect that one client. Everything else stays connected.
- Each row shows when it was last used and what called in, so "is it actually working?" stops being a guess.
A connection URL contains a secret token tied to your account alone — it can't reach anyone else's data. Anyone who has it can read and write yours, though, so treat it like a password.
Admin → People → Invitations → type an email → Send invite. They get a link, pick a password, and land in their own empty workspace.
Email is optional. Until you set up Resend, creating an invite gives you a link to send however you like — it stays valid for 14 days. Nothing is blocked on email being configured.
Leave the password field empty and it works as above: they choose their own and nobody else ever sees it. Type one, and the accept page asks only for their name — useful when you're setting somebody up over a call, or when the person you're inviting would rather be handed a login than invent one.
The password is deliberately never in the invitation email. A message carrying both the link and the password it opens is the whole account, sent to an address nobody has proven yet, so you have to pass it on some other way. The app says so when you create the invite.
Tick make them replace it when they first sign in and the app stays shut to them until they set one of their own — which is the thing that takes the password back out of your hands. Leave it unticked and the password you chose is simply theirs.
You can change your mind afterwards. Every outstanding invitation has a key button beside its link: add a password to one you already sent, replace the password on it, or take it off so they pick their own again. The link is untouched, which is the whole reason this is a separate thing from re-inviting them — re-inviting mints a fresh token and kills the link they may already be holding.
Resetting a member's password works the same way. Admin → People, open the person or use the row menu, then Reset password. Empty generates a passphrase, which is easier to read down a phone line and harder to guess; type one if they asked for something specific. The same checkbox is there, and it defaults off either way — you decide each time whether they keep what you gave them.
By conversation: admin_invite_user and admin_reset_user_password both take password and
must_change_password, and admin_set_invite_password changes one that is already out there.
If you run a landing page in front of your instance, point its sign-up form at
POST /api/waitlist with a JSON body of {"email": "...", "name": "...", "context": "..."}.
Only email is required. Requests land in Admin → People → Waiting for access, and you get an email the
moment one arrives — no polling an empty screen.
Nothing on that list has access to anything. A request becomes an invite when you press Invite next to it, which is the same invite as any other: a link, 14 days, their own empty workspace. The row stays afterwards, stamped with the date, so the list is a record of who asked and when.
The endpoint is open to any origin on purpose — that's what makes it work from a static site you host anywhere, with nothing to configure. It grants nothing, it never reads anything back, and it answers identically whether or not an address is already on the list, so it can't be used to find out who signed up. A honeypot field, a unique index on the address and a burst ceiling keep the obvious junk out; anything that gets through is one click to delete.
By conversation: admin_list_waitlist, admin_invite_waitlist_signup,
admin_remove_waitlist_signup.
If you host an instance for other people and want them to pay for it, wire it to Stripe
from Admin → Configuration: paste an API key — a restricted key with read-only Customers and
Subscriptions is all it needs, and safer than your full secret key — and a webhook signing secret, register the
webhook URL the panel shows you, and put your Stripe Payment Link wherever you send people.
Someone new who pays through the link is invited automatically. If their subscription
lapses they're suspended — sign-in and assistant access stop, data stays — and paying again
turns them back on. An existing member who starts paying is connected by you, on purpose,
with admin_link_billing: the checkout email is whatever the payer typed, so the webhook
never attaches a subscription to an account that already exists. The owner and anyone you
invited for free are never touched by billing, and a Resync from Stripe button
reconciles everything if a webhook ever goes missing. All of it is also reachable by
conversation: admin_get_billing_config, admin_set_billing_config, admin_sync_billing,
admin_link_billing.
| Role | Can do |
|---|---|
| Owner | Everything. Created at setup, can't be demoted or deleted. One per instance. |
| Admin | Invite people, suspend/delete members, configure email. |
| Member | Their own workspace. Never sees the admin area. |
Admins manage accounts, not content. There is no way — through the UI or through Claude — for one person to read another's career history, resumes or applications. That's enforced at the data layer: every query is scoped by owner, and it's a required argument the compiler won't let a caller omit.
Admin → Configuration → Email:
- Make a free account at resend.com.
- Add and verify the domain you want to send from.
- Create an API key, paste it in, and set a from address on that domain.
- Save, then Send test to prove it works — if it fails you get Resend's exact reason, which is almost always an unverified domain.
Five emails leave an instance: the invitation somebody gets when you add them, the notice you get when a stranger asks for access, the Monday summary and the due-today nudge for whoever asked for those, and that test. They carry the instance name and the mark, and they follow the same near-monochrome palette as the app, dark theme included. Send test picks which one goes out, so you can read the invitation in your own inbox before anyone else gets it — the sample is the real design with placeholder details and a link that goes nowhere, so proofreading it costs nobody a real invitation.
You can do all of this by talking to Claude instead: "is email set up? configure Resend with this key, then send me the invitation email so I can see it."
Passwords always work. Add a Google OAuth client from Admin → Configuration → Sign-in
and a Continue with Google button appears on the sign-in page too; clear the client ID
and it goes away again. The screen shows the exact redirect URI to register in the
Google Cloud console, with a button to
copy it — pasting it character for character is the whole of avoiding
redirect_uri_mismatch.
The button is not a way around invitations. Someone coming back from Google is matched in this order: an account that has used Google here before, then an account with the same email address if the instance has a reason to vouch for that address (the two get linked, and they keep their password), then an unexpired invitation (accepted on the spot, with no password ever chosen). Only if none of those match does the sign-up setting decide, and it is off by default — so an invitation you already sent starts working with the Google button the moment it's configured, and nobody else gets in.
That vouching matters: anyone can change their own email here to any unused address, so matching on the address alone would let a member set theirs to a colleague's and capture that colleague's first Google sign-in. An address counts when an admin addressed an invitation to it, the owner claimed the instance with it, or Google handed it over verified. Retyping it in Settings clears that, and the way back is to sign in with a password and press Connect Google under Settings → Account.
Turning sign-up on is a real change: anyone who can sign in to Google gets an account. Pair it with an allowed-domains list unless you mean the whole internet. A Google sign-up is always a member, never an admin, and an unverified Google email is refused outright — accounts here are matched by address, so that check is what the whole thing rests on.
By conversation: admin_get_google_config, admin_set_google_config.
Each person can connect the accounts recruiters actually write to, under Settings → Connections, and the app reads them live: a contact's page shows the threads with their address and the meetings they are invited to, a company's page shows everything from its domain, an application's page shows both under its timeline, and the pipeline's calendar view carries the interviews from the real calendar. More than one account merges. Nothing from any account is copied to the server: every page asks the provider when it opens, and disconnecting revokes what can be revoked and deletes the credential.
Three kinds of account:
- Google uses the sign-in client above. Two more things in the Cloud console make it
work: enable the Gmail API and the Google Calendar API, and add the
gmail.readonlyandcalendar.readonlyscopes to the consent screen. Gmail's read scope is one Google calls restricted, so leave the consent screen in Testing and list the people who will connect as test users rather than going through verification for an instance you host for friends. - Microsoft 365 and Outlook.com need an app registration in Microsoft Entra, set under
Admin → Configuration → Accounts: supported account types set to any directory plus
personal accounts, a Web redirect URI the screen shows you, the delegated Graph permissions
Mail.Read,Calendars.Read,User.Readandoffline_access, and a client secret. - Anything else — Fastmail, iCloud, Yahoo, a university account, a self-hosted server — connects by IMAP and CalDAV with an app password, from a form with presets for the common ones. It needs nothing from an admin. Either half can be left out.
The app never sends, so there is no SMTP to configure: read-only is what makes handing over an inbox safe, and the permissions it asks for cannot do anything else.
By conversation: list_linked_accounts, connect_imap_account, test_linked_account,
disconnect_account, list_correspondence, search_email, get_email_thread,
search_calendar, and the inbox_review workflow that reads a week of mail and proposes
what to log. Admins: admin_get_microsoft_config, admin_set_microsoft_config.
DATABASE_URL is the only thing this app asks of its host. Every other setting lives in the
database, so Admin → Configuration is all of it on one screen — instance, email, billing
and anything you add — with what each setting does written next to the box you type it in.
Change one and it takes effect on the next request; there is nothing to redeploy.
Secrets show masked and can only be replaced or cleared, never read back. Anything you change is one line in Admin → Log, with your name on it, values included for everything that isn't a secret. Clearing a value resets it to the default the app ships with, and the button tells you what that is before you press it.
One of those settings is worth calling out because it changes what somebody sees before they sign in. Landing page is the marketing site in front of your instance, if you run one. Point it at a site on the same domain as the app — hired.tools and app.hired.tools — and signing in leaves a flag on the domain above both, so anyone already signed in who lands on the marketing page is sent straight through to the app instead of reading the pitch again. The flag says a session exists and nothing else; it carries no identity and no token, and the session itself never leaves the app. Leave it empty and nothing is written.
You can also add a setting of your own. That's the escape hatch for one that exists before it has a section — a feature can read a key, and you can set it today rather than waiting for a screen. Keys are lowercase letters, numbers and underscores.
By conversation: admin_list_variables, admin_set_variable, admin_delete_variable.
157 tools. One hundred and forty-eight of them are the data tools across the five areas, the archive that cuts through all of them, your mail and calendar accounts, and your own account; the other nine are the workflows below, published as tools as well as prompts, because prompt support is optional in MCP clients and tool support isn't. Call one and it hands back a step-by-step plan that it then follows. Admins get 33 more — 32 data tools and a tenth workflow — and members never even see those in the tool list, so nobody is tempted by a permission they don't have.
| Workflow | What it does |
|---|---|
| Tailor a resume to a job | Reads a posting, mines Me for real evidence, drafts and saves a tailored resume, and tells you what it couldn't back up. |
| Gap report | Checks a posting against Me before you write anything: which requirements you can evidence, which are thin, which are missing — and the questions that would fill the gaps. |
| Mine a background into highlights | Turns a raw, rambling background into polished, reusable resume bullets. |
| Weekly pipeline review | What's stalled, who needs chasing, what to do next — with the follow-up messages drafted. |
| Research a company into the CRM | Gathers what's known, works out what's missing, and writes it back to their record without flattening what was already there. |
| Prepare for an interview | Pulls the posting, the timeline, the company research, the people involved and your own evidence into one prep sheet. |
| Write a letter | Gathers the posting, your evidence and the letters you have already written, then drafts a cover letter, a cold message, a referral ask, a thank-you or a reply in your own voice. |
| Log what happened this week | You ramble; it files everything to the right role, application, or note. |
| Bring the pipeline up to date from your inbox | Reads a week of your mail and calendar, tells you what moved, and queues what to log on your dashboard — nothing is written until you accept it. |
| Invite and onboard someone (admin) | Invites a person, hands you the link if email isn't set up, and drafts the message to send them. |
Every client is instructed never to invent experience, employers, dates, or metrics. If there's no evidence in Me for something a job asks for, it says so instead of making it up.
Every tool also declares what it does to your data — whether it only reads, whether it can overwrite or delete, whether it reaches anything outside this instance. Claude sorts its approval screen by that, so you can hand over the whole read side of the server at once and still be asked before something gets destroyed. And when a tool's entire job is to give you a link — a published resume, a rendered PDF, a shared pipeline — it comes back as a link you can click, not a field buried in a blob of JSON.
docs.hired.tools is the manual — it's Docs in the profile
menu, and every tool is written out there with its arguments, generated from the same array
the server sends so it can't drift. The app used to render its own copy of that list at
/docs; one generated list rendered twice is one rendering that goes stale, so that page
is gone and the address redirects.
Settings → Connections keeps the parts only your own instance can answer: how many tools
your account actually has, a Test that proves it by calling the endpoint, and the three
Claude Skills, which teach an assistant the rules of this place before you have to. Each
comes two ways — the raw SKILL.md to drop in ~/.claude/skills/, and a zip for the upload
box in Claude's apps, which wants a folder rather than a loose file. They're served from the
skills/ directory of the instance you're running, so what you install is byte-for-byte what
it has.
Me — search_me, get_me_snapshot, roles with unlimited backgrounds
(append_role_background adds without overwriting), reusable highlights, notes and standing
rules, plus education, projects, skills and certifications, which
create_extra / update_extra / delete_extra maintain. import_resume is the way in for
anyone who already has a resume: paste it to your assistant and the whole thing gets filed in
one call — roles with their bullets, education, skills, contact details — without overwriting
anything already there.
Resumes — get_resume_format describes the document shape, then create_resume /
update_resume / duplicate_resume build and tailor them. preview_resume_text renders a
draft and estimates page count without saving, so Claude can check length before
committing, and check_resume_fit ranks what to cut when it runs long — the longest
bullets, and which sections are carrying the most weight. reorder_resume moves one
section, job or bullet without rewriting the document, so "lead with the Stripe job" costs
one call rather than a full rewrite, and add_role_to_resume brings a job in from Me — its
dates and its strongest bullets — without touching anything else. publish_resume turns one into a shareable link and hands back the URL;
unpublish_resume destroys it. export_resume_pdf renders a real PDF server-side and
reports the page count it actually came out to. A duplicated resume remembers what it was
tailored from, so compare_resumes can say exactly what a variant changed — bullets added,
dropped, reworded — and list_resumes carries each resume's track record: how many
applications it went out with, how many reached an interview, how many reached an offer.
Letters — prep_letter first, always: it gathers the posting, the company research, the
last few things on the timeline, the material in Me that matches, and up to three letters of
the same kind you have already written, which is what a draft matches for tone. Then
create_letter saves it, and list_letters / get_letter / update_letter /
delete_letter are the rest. Filter by kind, by job, by person, or by whether it is still a
draft.
Pipeline — capture_job_posting turns a posting URL into a tracked application in one
move, company and description included. capture_job_postings does the same for a morning
of open tabs, and will not put the same role on the board twice. Then applications and stages, an activity timeline,
tasks — list_tasks, create_task, update_task, complete_task, delete_task, each
task about at most one thing and that thing being an application, a company, a person, a
resume, a role in Me, a note, or nothing at all —
list_follow_ups for
what's overdue, list_schedule for a whole window of dated work at once, pipeline_stats for
the shape of your search, and diagnose_search, which reads the funnel and tells you which
step is losing people rather than handing you six numbers to interpret. export_csv returns
any of the three lists as a spreadsheet, get_pipeline_fields / set_pipeline_fields choose
how much each view shows before you open anything, get_column_widths /
set_column_widths are the same idea for how wide each table column is, and
list_field_values says which locations and work modes you already use, so a new application
does not become the third spelling of Remote.
What somebody actually offered is its own record: record_offer writes a new version every
time, compare_offers puts the live ones side by side and refuses to convert currencies, and
offer_briefing gathers everything that bears on one negotiation. list_stage_templates and
its four siblings are the checklist a stage move fires. propose_changes queues suggestions
for you to accept on the dashboard rather than asking about each one in the chat, and
list_proposals / accept_proposal / dismiss_proposal are the rest of that.
CRM — list_companies / get_company / create_company / update_company /
delete_company for the companies you're talking to, and get_contact / update_contact /
delete_contact for the people at them, each carrying every way to reach them rather than
just a LinkedIn URL. A company's website is what puts their logo on your
pipeline. Deleting one archives it and takes its applications with it, and the people at it
stay where they are.
Archive — list_archive says what has been deleted and when each thing is due to go,
restore_records brings it back, and delete_archived and empty_archive are the only two
acts on the server that cannot be undone. Neither can reach anything that is not already in
the archive, and empty_archive refuses unless you pass back the count list_archive just
reported — which forces reading the bin to somebody before emptying it.
Tags — list_tags, create_tag, update_tag, delete_tag and seed_tags manage the
one catalogue behind all of it. A tag's kind says which list it belongs to — where an
application came from, a company's industry, size or location, how you know a person — so a
location called Remote never collides with a way of working called Remote. Every writer
takes names or ids: names fold case and are created only when nothing matches.
Your account — whoami says who this connection belongs to. list_connections,
create_connection, rename_connection, rotate_connection and delete_connection manage
the wiring itself, so "add this to my work laptop" and "kill the one I pasted in a chat by
mistake" are things you can just say. Listing never returns tokens — creating and rotating
do, because that is the point of them. set_profile_photo takes a link or a file and sets
the picture described below. get_digest_settings / set_digest_settings turn the two
emails on and off, preview_digest builds either one without sending, and
export_everything / import_everything take the whole workspace out as one file and put
it back.
Admin (admins only) — admin_list_users, admin_invite_user, admin_set_user_role,
admin_set_user_active, admin_delete_user, admin_instance_stats, plus
admin_get_email_config / admin_set_email_config / admin_send_test_email for wiring up
Resend without leaving the conversation, and admin_list_variables / admin_set_variable /
admin_delete_variable for every other setting the instance stores. These act on accounts
and instance settings only — none of them can read another person's content.
The empty workspace is the reason people leave before they start. Ask Claude — "here's my
resume, file it" — and import_resume puts the whole thing in: jobs with their bullets,
education, skills, contact details, without overwriting anything already there. That path
reads the document properly, and it's the one to use.
Import the same resume again a year later and it adds what changed rather than shrugging: a
job already on file keeps everything it has and gains the bullets it doesn't, with a reworded
line recognised as the one you already had. preview_resume_import says what a second import
would do before it does it.
If you haven't connected anything yet, Import on the Me page takes the text instead. It reads headings, so it's a draft rather than an answer, and it's built to be corrected: every job it found, and every bullet under it, is shown as an editable field before anything is saved. Where it had to guess — which half of "Northwind Trading — Head of Operations" is the employer, or which lines are bullets in a document that never used a bullet mark — it says so on the field it guessed about, rather than as a warning at the top you'd have to go hunting with. The whole document is filed as a note either way, so anything it missed stays searchable.
Importing ends where it should: if you had no resume, it builds one from what just landed and opens it. If you already have documents, it stays where it is and offers — topping up your material isn't the same as wanting a new draft, and the app shouldn't decide which you meant.
Paste LinkedIn and it's read as LinkedIn. The clipboard doesn't give you a resume — it repeats the employer, hangs "· 3 yrs 8 mos" off every date, and groups promotions under one company with the company named once, so read straight it produces jobs called "Full-time" and promotions filed under no employer at all. That gets folded in first, and the review step says so, because the fields won't match what you pasted line for line.
Read a PDF takes the file itself, when the file can be read. A PDF has no paragraphs — just glyphs at coordinates — and a two-column resume's text comes out interleaved, a line of your jobs then a line of your sidebar. So the layout is checked first: one column is read and dropped into the box for you to look over, and two columns is refused by name, saying that's what it is and what to do instead. A blanket refusal of every PDF was the old answer, and most resumes are one column and read fine.
New resumes use the Harvard OCS format by default — the one Harvard's career office hands out, and the one recruiters have read ten thousand times:
- Times-metric serif, everything at one size (10pt body, 11pt name and headings)
- Name and section headings centred over full-width rules
- Each entry is two justified lines: organisation / location, then role / dates
- Black and white, half-inch margins, disc bullets indented half an inch
- No accent colours, no columns, nothing an applicant tracking system can trip over
Because it leads with the organisation, fill in both company and title on every entry — Claude is told to do this. For a Leadership & Activities section, add an Experience-kind section and just rename the heading; organisation, role, location and dates all lay out correctly.
The format is a starting point, not a cage. The Design menu (palette icon in the editor) switches template, font, accent, size, leading and margins per resume. Sections, the jobs inside them and the bullets inside those all reorder by dragging the grip on the left — or with the ⌃/⌄ buttons, or from the keyboard: tab to a grip, press space, and the arrow keys move it. Harvard's own convention puts Education first — that's right for students and recent graduates, and wrong for most people with real work history, so the default order leads with Experience.
The preview is a way in, not just a picture: click any line on the paper — a bullet, a job, a heading, your name — and the card holding it opens in the rail with that exact field focused. It works because every block on the page already carries the place in the document it came from, which is how the page breaks know what to point at.
A job you left off doesn't need retyping: From Me, beside Add job, lists everything in your history and drops the one you pick in with its dates and its strongest bullets. The entry remembers which role it came from, which is what lets the next thing work.
Every bullet under a job carries a small mark: filled when something you wrote in Me stands
behind it, hollow when nothing does. Hollow doesn't mean the line is false — it means the
material behind it is missing, which is the thing worth fixing before an interview asks about
it. It's the same measure trace_resume_evidence uses, so the mark and the Evidence panel
never disagree, and it recomputes as you type.
Nothing you do in the editor is one-way. Undo and redo sit next to the save indicator and answer to ⌘Z and ⇧⌘Z — except inside a text field, where ⌘Z still takes back what you typed, which is what you meant. A sentence undoes as a sentence rather than a letter at a time. Deleting a job or a section also raises a toast that offers it straight back, because a delete is the one thing worth catching before the autosave does.
The other templates — Classic, Modern, Compact, Editorial — are all still there.
The editor draws a line across the preview everywhere the paper runs out, labelled with the page it starts, and says "splits here" when an entry is cut in half by the break. That's measured, not guessed: a second invisible copy of the document is laid out in page-sized columns and the browser is asked where it actually broke, so the line lands where the PDF will break and moves as you type.
When it runs to two pages the badge in the toolbar stops reporting the count and offers to
fix it. Open it and you get what is on the last page in order, the sections you could hide —
kept in the document, off the page, and undoable — and your longest bullets with the words on
the button that removes each one. check_resume_fit is the same advice over MCP, so "what
should I cut to get this to one page?" answers with the same list.
Settings → Account takes a profile photo. Drop a file in, drag it around the circle until your face is where you want it, and that one picture is your avatar in the app and the headshot on your resumes. Change it once and every document that shows it follows — there is never a second copy to keep in sync. Claude can set it too: "use the photo on my GitHub profile."
Whether a given resume shows it is a design choice like the accent colour: Design → Photo
in the editor, or showPhoto from a tool. It's off by default, and Harvard never renders one
whatever you set — it's a US academic format, and a face on it is the thing that marks it as
not that format. Classic centres the photo above your name; Modern, Compact and Editorial set
it beside. US and UK applications generally leave photos off; much of Europe and Latin America
expects one.
The picture lives in your row in the database as a data URI, not in a file store, which is why self-hosting still needs one environment variable and why a published resume paints your face from the same HTML as the text — nothing to fetch, nothing to expire. The browser crops and shrinks before uploading, so it costs tens of kilobytes.
Application forms keep asking for a URL, not a file. Open a resume → Share → Create a link, and you get an address like:
https://your-app.up.railway.app/r/staff-engineer-stripe-k7m2qx4bnp8t
Anyone with it can read that one resume without signing in. Ask Claude instead and you skip the browser entirely: "publish my Stripe resume and give me the link."
The privacy model is the address itself, and nothing else. It's long and random, so it can't be guessed or walked, the page tells search engines not to index it, and it's listed nowhere. Your private notes on the resume aren't on the public page — but if that resume has the photo switched on, your face is, so decide that before you publish. That's the whole model — there are no per-viewer permissions and no passwords, because everyone you'd send this to is someone you already decided to send it to.
Withdraw destroys the address rather than pausing it. The page starts returning "not found" for everyone immediately, and sharing that resume again gives you a different link — so a URL you regret sending stays dead.
Open a resume → PDF. The file downloads. There is no print dialog and no margin setting to get wrong.
The server renders the same page you'd have printed by hand, so what you see is what you get: exactly 8.5in × 11in with the Harvard template's half-inch margins. The output is real selectable text, not an image, so applicant tracking systems can read it.
Ask Claude instead and you get the page count with it — "export my Stripe resume" returns a download link and tells you it came out to one page, which is the thing you actually wanted to know before sending it.
No webfont is fetched: the serif stack is Tinos → Times New Roman → Liberation Serif, which are metrically identical, so the document renders the same on macOS, Windows and Linux with nothing to download.
If your host has no headless browser
Server-side rendering needs a Chromium on the machine. The Docker image ships one, so a
compose deploy has working one-click PDF from the first boot. Railway's Nixpacks image does
not — on a stock Railway deploy the PDF button reports that and the fallback still
works: ⋯ → Open print view, then your browser's Save as PDF with margins set to
None. Same document, one more step. export_resume_pdf says the same thing rather than
failing silently.
To get the one-click version the image needs a Chromium and a Times-metric serif, and then
PDF_CHROMIUM_PATH pointed at the browser (or one of the usual paths, which are checked
automatically). A Dockerfile installing chromium, fonts-croscore and fonts-liberation
gets there — that part is proven — but Railway's healthcheck did not come up on the
resulting container, so it isn't the shipped default yet.
- Everything autosaves. There is no save button anywhere. A small indicator tells you when a change has landed.
⌘K/Ctrl+Kopens a search palette that jumps to any role, resume, or application.- Follow-up dates set themselves when an application changes stage — 7 days after applying, 4 after a screen, 3 after a final round, each at 9am. Override any of them by hand.
- Dates are yours, not the server's. The browser tells the app what time zone you are in the first time you open it, and everything dated follows it: what counts as today, when a follow-up turns red, and which 9am it is set for. Change it under Settings → Account if you are searching from somewhere your laptop isn't.
- Dark and light both supported; toggle is top-right.
- Suspending someone signs them out everywhere and kills their Claude connection immediately — their data is kept. Deleting them removes it all.
Add RESET_OWNER_PASSWORD=1 to your app service's variables and redeploy. The app prints a
fresh owner password to the logs. Remove the variable afterwards, or it resets again on the
next deploy.
You need Node 20+ and a Postgres database.
cp .env.example .env # only DATABASE_URL is required
npm install
npx prisma migrate deploy
npm run devOpen http://localhost:3000. Your owner password is printed in the terminal on first start.
Issues and pull requests are welcome.
npm run typecheck # tsc, no emit
npm run build # production buildThe two things worth knowing before changing anything:
- Tenant isolation is a compile-time property. Every function in
src/lib/data/takes the owninguserIdas its first argument, and every query filters on it. Don't add a data function without one — the whole safety story rests on the compiler rejecting unscoped calls. - The MCP tools and the UI share one data layer. Anything you add in
src/lib/data/can be exposed to both; don't fork the logic.
AGPL-3.0 — see LICENSE.
In practice: self-host it, modify it, run it for yourself and your friends, all free, forever. The one obligation is that if you run a modified copy as a service for other people, you publish your modifications. If you'd rather keep changes private, run it unmodified — or talk to me.
Next.js 15 (App Router) · React 19 · Tailwind v4 · shadcn/ui · Framer Motion · Prisma · PostgreSQL.
The MCP server lives in src/lib/mcp/ and speaks the Streamable HTTP transport directly —
no session state, so it survives restarts and replicas without reconnecting. Tools are
defined once in src/lib/mcp/tools.ts and share the same data layer (src/lib/data/) as
the UI, so anything an assistant writes shows up in the app immediately and vice versa.
It answers two revisions of the protocol, picked per request rather than per connection.
The current one, 2026-07-28, has no handshake: a client says which version it is speaking
in each message, asks server/discover what is here, and gets results that carry their own
type and cache hints. Everything before it — back to 2024-11-05 — still opens with
initialize and still works, because every client shipping today does that. Statelessness
used to be this server's own idea and is now the protocol's, so nothing had to change
underneath: era is a pure function of one message. A browser origin that is not this
instance's own is refused outright; Extra MCP origins, under Admin → Configuration, is there
for the rare client that needs to be let in.
The token lives in the URL path (/api/mcp/<token>) because that is the one shape every
client can express — no headers to configure, no OAuth discovery. Clients that insist on a
header can send Authorization: Bearer <token> to /api/mcp instead; both routes resolve
to the same connection. Setup recipes are data, in src/lib/mcp/clients.ts — adding support
for a new client is one entry in that array, no UI changes.