freehire / extension
AGENTS.md3.5k starsChanged 3 months agoMITView on GitHub
Guidance for AI agents working in this directory. A browser extension that puts a job-application agent in a side panel — a Claude/Gemini-style side bar with access to whatever page the user is on. The agent is freehire's own, running inside hire's API (internal/ai/assistant): the panel holds a conversation under the browse preset, whose agent can read the open page through the browser-tool relay this extension serves. Current state: the chat runs on hire's assistant. Sign in with freehire and…
- Reads credentials
- Installs packages
We split this AGENTS.md into the rules it gives an agent
2 rules. About 0 are common, 2 are distinctive, and 0 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
0
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 25
No local server, and no separate agent service.
- Distinctive: line 90
Read-a-page flow — note that the panel is not the one deciding:
≈ “Common” is an estimate: at least 1 in 5 of the 36,368 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
AGENTS.md
Guidance for AI agents working in this directory.
What this is
A browser extension that puts a job-application agent in a side panel — a
Claude/Gemini-style side bar with access to whatever page the user is on. The
agent is freehire's own, running inside hire's API (internal/ai/assistant):
the panel holds a conversation under the browse preset, whose agent can read
the open page through the browser-tool relay this extension serves.
Current state: the chat runs on hire's assistant. Sign in with freehire and
talk to it; there is no "read page" affordance, because the agent calls
read_current_page itself when a question needs it. A profile-aware match card,
page intake and agent-driven Autofill also ship.
Everything reaches one origin. Auth crosses it with the session JWT the connect
flow minted: Authorization: Bearer on the HTTP API, the freehire-jwt
subprotocol on the relay's WebSocket (a browser cannot set headers on
new WebSocket).
Stack: WXT + Svelte (Chrome MV3; Firefox MV2 from the same source), styled on freehire-design-system
(Tailwind v4 tokens + primitives — see the root AGENTS.md's design-system row).
No local server, and no separate agent service.
Layout
extension/ WXT + Svelte MV3 extension (npm-managed; the repo's other
JS packages, web/ and design-system/, are pnpm)
entrypoints/
background.ts service worker: opens the panel, relays panel <-> content
content.ts injected everywhere; reads the page into a PageSnapshot
sidepanel/ Svelte chat app (owns the relay WebSocket)
App.svelte chat UI + match card + apply plan + page intake + Autofill
MatchCard.svelte profile-match card
ApplyPlan.svelte the open form's questions, answered or not, + the counter
ToolGroupList.svelte what the agent is doing, mid-turn
JobDeck.svelte / JobDeckCard.svelte the `present_jobs` cards
app.css Tailwind + freehire-design-system/theme.css import
main.ts Svelte 5 mount
lib/
assistant/ the chat: SSE reader, wire types, chat + deck reducers,
tool formatters, the session store, and the API and turn
clients (+ tests). Ported from the freehire web assistant.
tools/ browser-tool executor: the wire contract, the executeTool
dispatch (+ tests), the ToolChannel socket to hire's relay,
and the PageBridge that reaches the tab's frames.
auth.ts "Sign in with freehire" (launchWebAuthFlow) + token storage
analytics.ts anonymous usage events to GA4 via the Measurement Protocol
(gtag.js is remote code MV3 forbids); inert without the
WXT_GA_* build vars (+ test)
freehire.ts hire API reads (job, match, autofill profile)
protocol.ts in-extension RuntimeMessage contract (+ test)
scraper.ts DOM -> PageSnapshot + JobPosting JSON-LD check, pure over
its Document arg (+ test)
addJob.ts whether the empty match state offers "Add this job" (+ test)
form.ts form observe/map/act/reveal for Autofill (+ test)
applyPlan.ts form fields -> the panel's checklist + required counter (+ test)
walk.ts the order an autofill works through the form, as a value (+ test)
debounce.ts one call after a burst goes quiet (+ test)
combobox.ts drives custom-widget comboboxes (open/options/select/verify);
pure over its Document like form.ts, but async (+ test)
wxt.config.ts manifest (permissions, side_panel, host_permissions) +
the Tailwind Vite pluginArchitecture
- Side panel (Svelte) owns the relay WebSocket. It's the only context that stays alive while open — unlike the MV3 service worker, which Chrome kills when idle, so it must not hold the durable socket. The chat needs no socket at all; each turn is its own request.
- Content script reads the live DOM on request. It holds no state.
- Background is a thin relay: the panel can't message a content script directly, so background forwards a snapshot request to the active tab.
A turn is one POST whose response body streams SSE (lib/assistant/): create
a conversation once (POST /assistant/sessions?preset=browse), then post each
message and fold the streamed TurnEvents into the message list. Nothing is held
open between turns, and cancelling is aborting the fetch — the backend notices its
next write fail and stops before spending another model call.
The conversation id lives in storage.local, so closing the panel and reopening
it resumes; the transcript is replayed from the server through the same reducer a
live turn folds through. A conversation deleted from the web starts a fresh one
silently.
Read-a-page flow — note that the panel is not the one deciding:
hire's agent --read_page--> relay --> panel
panel --GET_PAGE_SNAPSHOT--> background --(active tab)--> content
content --PAGE_SNAPSHOT--> background --> panel --> relay --> the agent, mid-turnConventions
protocol.tsis the in-extension contract —RuntimeMessage(panel <-> background <-> content, discriminated bykind). Neither the chat's wire nor the browser-tool wire is here: they arelib/assistant/wire.tsandlib/tools/wire.ts, each mirroring a contract hire owns.- Autofill is watched, not batched. The panel walks the form one question at a
time (
lib/walk.tsholds the order as a value; App.svelte supplies the ~300ms pauses), each fill carryingrevealso the page scrolls to the question and outlines it as the value lands. The pause is for the eye — the walk IS the audit the user would otherwise have to do afterwards. The agent path fills server-side as before and the panel plays its report back over the page, revealing each label it reported without re-writing anything. - The plan is the panel's standing account of the form, computed by
buildPlanfrom what the page reported and rebuilt on every page change, after a walk, onFORM_CHANGED, on returning to the Match tab and on the panel regaining focus. It counts REQUIRED questions only (they are what gates submission) and is null for a page showing no application form. - Two different tests for "this is an application".
looksLikeApplication(a CV upload) gates the FILLER — it is what stops a job-alert signup being written into.showsApplicationFormgates the CHECKLIST and is deliberately looser: an upload, OR four labelled questions, OR a form the panel has already written into. The upload sits on step ONE of a multi-step ATS form, and every step after it — the screening questions — has none, which is why the strict test showed no checklist where it was most wanted. Nothing is written on the strength of the looser one. PlanItem.keyisframe:index, never the label. Two questions on one form can carry the same label, and a keyed{#each}over a duplicate throwseach_key_duplicate— which takes the whole card down and looks, from the panel, like the feature simply not existing. That was the real reason the checklist never appeared on a live ATS form.- A fill names the scope it was read from, and refuses what it cannot resolve.
read_formreportsframeandformon every field, andfill_simplecarries both back:framepicks one of the tab's documents,formone<form>inside it. Where a label matches more than one question and the fill names noform,fillByLabelwrites NOTHING and reportsambiguous— a careers page routinely carries its application beside a job-alert signup, both asking for an email address, and taking the first match writes the candidate's details into a form they did not choose and calls itfilled. The three refusals are kept apart because each implies a different next step:ambiguousmeans re-send naming a form,wrong_formmeans the index is wrong,not_fillablemeans no re-send helps until the page changes. This is also whyreadFillsmust not drop a field it is sent — hire tagged every fill with its frame for months while this reader destructured only{label, value}, so the tag never arrived and every agent fill was broadcast to every frame and matched by label alone. Both sides' tests were green: one checked the sender, the other checked this reader against arguments written by hand beside it. What catches that now is one fixture written from hire's live struct (lib/tools/testdata/fill-simple-call.json, regenerated byUPDATE_WIRE_FIXTURE=1 go test ./internal/ai/autofillagent/) and read back by this side's own test. - Form reads are serialised, not raced. A request-id guard (the
matchRequestIdpattern) starves here: an ATS page announces DOM changes continuously, so every read finds its id already superseded and returns before assigning. One read at a time with a re-read queued behind it cannot starve and cannot land stale. For the same reason the page announces a mutation only when the NUMBER of controls changes — typing is covered by the input/change listeners, where it does not. - "Add this job" is offered only on a plausible vacancy (
offersAddJob): a URL/jobs/findreports as a recognised ATS board (meta.ats_board, from the server'satsboard.Recognize— never copy its host list here), or a page carrying schema.orgJobPostingJSON-LD. Offered everywhere, it filled the triage queue with LinkedIn feeds, chat apps and localhost (63 submissions, nearly all junk). revealFieldborrows the page's style and gives it back. The outline is inline style, restored after ~600ms: the extension runs on pages freehire does not own, where an injected class can collide with the ATS's own and a stylesheet outlives our interest in the element.- The agent's reading is bounded, and visible.
read_pagerefuses any tab that is nothttp(s)(lib/tools/readable.ts), decided from the url before the page is read — this extension is the only side that sees a url before scraping it. The panel then names the page each read touched, minus query and fragment, which is where session tokens live. That display lives inlib/assistant/pageRead.tsrather than intool-formatters.ts, precisely because the latter is a verbatim port andread_current_pagecannot occur in the web app. lib/assistant/mirrors the freehire web assistant (web/src/lib/assistantin this repo).sse,wire,chat,deckandtool-formattersare verbatim ports — keep them aligned. Three files diverge, and they say so at the top:api.ts/client.ts(absolute origin + Bearer, since extension code has no cookie) andjobCache.ts(reads the token the web's cookie made implicit).- Test the logic, not the transport.
scraper,form,protocol, the assistant's reducers and its API clients are tested (vitest); the chrome message plumbing, the live socket andstorage.localhelpers are thin glue. - TypeScript stays on 5.x — svelte-check does not yet support the native
typescript@7(tsgo). - Styling comes from
freehire-design-systemvia afile:../design-systemdependency (npm's local-symlink protocol — pnpm'slink:has no npm equivalent by that name). Components not covered by an existing primitive keep local scoped CSS, but reference the package's CSS custom properties (var(--brand),var(--muted-foreground), ...) rather than literal colors.design-system/must have its own dependencies installed (cd design-system && pnpm install) beforeextension/will build — same gotcha the rootAGENTS.mddocuments forweb/: afile:/link:-linked package's own dependencies are not pulled in by the consumer's install.
Commands
# hire must be reachable at WXT_HIRE_ORIGIN (default: the local SPA at :5173,
# which proxies /api). Any signed-in account will do — the assistant is open
# to every signed-in user.
cd design-system && pnpm install # once, and after pulling design-system changes
cd extension && npm install # runs `wxt prepare` (generates .wxt/)
cd extension && npm run dev # dev build with HMR
cd extension && npm run build # production build -> .output/chrome-mv3
# targets freehire.me
# (extension/.env.production); dev keeps
# the localhost default
cd extension && npm test # vitest: lib/**/*.test.ts
cd extension && npm run check # svelte-checkLoad unpacked in Chrome: chrome://extensions → Developer mode → Load
unpacked → extension/.output/chrome-mv3. Click the toolbar icon to open the
panel.
Firefox
npm run dev:firefox / npm run build:firefox (-> .output/firefox-mv2) /
npm run zip:firefox (the add-on plus the sources ZIP addons.mozilla.org reviews;
publishing is in docs/firefox-addons.md). Load it at
about:debugging → This Firefox → Load Temporary Add-on → the build's
manifest.json. The sidepanel entrypoint becomes Firefox's sidebar_action,
and the toolbar click toggles it (background.ts).
Two differences, both in wxt.config.ts:
- No Attach tailored CV. It needs
chrome.debugger, which Firefox does not have, so the panel hides the button (import.meta.env.FIREFOX). - A different sign-in redirect. Firefox's
identity.getRedirectURL()ishttps://<sha1(gecko id)>.extensions.allizom.org/, so hire'sEXTENSION_REDIRECT_ALLOWLISTmust carry that hash beside the Chrome ids. Forextension@freehire.meit is13f2644fb08a641dc52d5b7c11faf2fb9775ee26; the gecko id is therefore as fixed as Chrome'skey.
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 28,693 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repository4 other files it gives its agents.
- AGENTS.mdAGENTS.md
- design-system/AGENTS.mdAGENTS.md
- web/AGENTS.mdAGENTS.md
- .cursor/rules/commit-checks.mdcCursor rule
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

