Skip to content

Releases: profullstack/agenticjobs

0.10.1: the nav button is readable without hovering

Choose a tag to compare

@ralyodio ralyodio released this 09 Sep 15:47
dfc6ebd

The header's "Post a job" button (and "Sign in" when signed out) was unreadable
until you hovered it.

.nav a is more specific than .btn, so the button painted the nav link's
--muted-foreground on the button's own --primary background: oklch lightness
0.53 on 0.52 in light, 0.71 on 0.72 in dark, a contrast ratio of about 1.05:1
either way. Hover only looked like a fix because the nav's hover rule repainted
the element as a plain grey pill, throwing away the button's background and its
own hover state with it.

The nav link rules are now scoped with :not(.btn), so a button in the nav is
left to .btn and its --primary / --primary-foreground pair: 4.89:1 in
light, 8.32:1 in dark, with the darker-green hover a button should have.

The service worker VERSION follows the shell asset hash, so returning readers
get the new stylesheet instead of the cached one.

0.10.0: write a listing with an agent

Choose a tag to compare

@ralyodio ralyodio released this 09 Sep 12:26
c319f35

Write a listing with an agent

An employer with a brief and no time can have a model expand it into the post form. Set OPENAI_API_KEY or ANTHROPIC_API_KEY and the box appears on /post; set neither and it does not.

It fills the form and stops. Nothing is written, nothing is published, and the person who asked reads and edits every field before a listing exists. That is the same seam an employer's agent goes through when it posts over the API and lands a draft, which is the board's thesis rather than a feature bolted onto it.

It will not invent compensation. If the brief says nothing about pay, every salary field comes back empty: an employer may not notice an invented range, and a candidate who applies because of a number nobody agreed to has been misled by this board. "Unpaid" has to be stated rather than inferred from silence, and it arrives as the checkbox 0.9.0 added.

Anything the model returns is checked against the same lists the form's own selects are built from, and an unrecognised value is dropped rather than bent into the nearest legal one, which would look like the employer chose it.

Two providers, because this is MIT software other people self-host and a board that only works if you bank with one vendor is not self-hostable. WRITER_MODEL overrides the per-provider default (gpt-5.2 / claude-opus-5). Written against both REST APIs directly rather than either vendor SDK.

Not a public text generator. Drafting needs an account and is capped at ten an hour per account, recorded before the model is called rather than after, since a request that failed still cost the board something. The provider's own error text never reaches the page.

OPENAI_API_KEY=sk-...          # or ANTHROPIC_API_KEY=sk-ant-...
WRITER_MODEL=gpt-5.2-codex     # optional

Full changelog: v0.9.0...v0.10.0

0.9.0: an unpaid role can say so

Choose a tag to compare

@ralyodio ralyodio released this 09 Sep 12:05
f2d36f6

An unpaid role can say so

There was no way to post an unpaid internship.

Leaving the salary range empty already meant "the employer did not fill this in", so an honest unpaid listing was indistinguishable from a careless one. Putting 0 in the range was worse: it sorts and filters as a paid job worth nothing.

salary.unpaid is a boolean for that reason. When it is set the range stays null, so every salary filter and the salary sort keep excluding these listings with no special case anywhere, and the page prints Unpaid instead of nothing.

Unpaid wins over any number that arrives with it. A form can post a stale range alongside a ticked box, and "unpaid, $40k - $60k a year" is not a listing anybody can act on.

Posting one

Surface How
/post a checkbox in the Pay fieldset
REST salaryUnpaid: true
CLI agenticjobs post --salary-unpaid
Job file front matter salary_unpaid: true

schema.org has no vocabulary for unpaid work, so baseSalary is absent, which is what it already did for a null range. docs/openjob.md documents the field.

Also in this release

CI could not fail on a failing test. The step ran node --test | tee output.txt under a shell without pipefail, so the exit code came from tee, and the script ended in exit 0. Fixed in #39 by @KennerMiner. Every check on this release is the first one that could actually have gone red.

Full changelog: v0.8.0...v0.9.0

0.8.0: a resume is public, the way to reach the person is not

Choose a tag to compare

@ralyodio ralyodio released this 09 Sep 11:51
3ff4b37

A resume is public, the way to reach the person is not

Anyone could read a published resume and take the candidate's email address and phone number out of it. Walking /candidates and following each link produced a mailing list with phone numbers attached, which is not what somebody agreed to by publishing a resume in order to be hired.

Contact channels now need a session or a token. Everything else about the resume stays public, because a candidate directory nobody can read is a candidate directory nobody uses.

What counts as a channel falls out of the parse rather than a list of field names, so it does not go stale as people invent new fields. A contact bullet that produced a link (mailto:, tel:, a profile URL) is a way to reach someone and is withheld. One that did not is a plain fact and stays, so Location: Lisbon and Work Authorization: EU Citizen survive. The directory already prints the location on every card and filtering on it is the point.

Withheld, not silently dropped. The bullets are replaced by - **Contact**: shared with signed-in members, and the JSON carries contactRedacted: true. A caller that cannot tell a redacted document from a resume with no contact details will report the second as the first.

Every representation, because they all render the same Markdown: the page, /api/v1/candidates/:slug, and resume.md, .html, .pdf, .docx. An original uploaded PDF or DOCX cannot be redacted, so an anonymous caller no longer gets those bytes and gets a copy generated from the redacted Markdown instead.

The gate is "signed in", nothing more. A person with a session and an agent with a device token are the same caller, since an agent reading resumes for its owner is the traffic this board exists to serve.

On the version number

v0.7.0 was tagged and published to npm before this work merged, so 0.7.0 names two different builds: the release and the npm package carry the employer decision flow but not the contact gate. 0.8.0 is the first version that has it. If you installed 0.7.0 expecting this, upgrade.

Full changelog: v0.7.0...v0.8.0

v0.7.0: an employer can decide on an application

Choose a tag to compare

@ralyodio ralyodio released this 09 Sep 11:09
1c6abcb

An employer can decide on an application.

applications.status has carried five values since the first migration, and two were reachable. A candidate's draft became new on submit and stopped there. reviewing, rejected and hired were declared in the schema, rendered as a badge on the employer's page, and set by no code path in the web app, the REST API, the CLI or the MCP server — setApplicationStatus sat in core with zero callers.

So an employer could read what came in and could do nothing about it. Every applicant sat at new forever, and the badge told them nothing.

The action, on every surface

REST POST /api/v1/applications/{id}/decision
MCP decide_application
CLI agenticjobs decide <id> <status>
Web Buttons on the employer's own page

agenticjobs applications <slug> now prints each application id, so there is something to pass to decide.

What it does not do

It does not email the candidate. The board records what you decided; telling them stays yours, because a board that sent the rejection for you would be writing in your name.

Permission is one statement

The membership test lives inside the update rather than in a check before it. The application id arrives from outside and is the only thing a caller would have to guess to write into another employer's pipeline.

A caller who is not a member and a caller who named an application that does not exist get the identical 404. Distinguishing them would tell an unauthorised caller which ids are real.

Drafts cannot be decided on, for the same reason they are not listed: an employer cannot see one, so an employer cannot reject one out from under a candidate who has not sent it yet.

Attribution

decided_at and decided_by. An employer is a group, and "who moved this to rejected" is the first question its members ask when they disagree. decided_by sets null rather than cascading, so deleting an account does not rewrite the history of a decision it made.


npm i -g @profullstack/agenticjobs@0.7.0, or agenticjobs update.

Full diff: v0.6.1...v0.7.0

0.6.1: the download routes actually match

Choose a tag to compare

@ralyodio ralyodio released this 09 Sep 10:46
ca804f0

The resume downloads actually match (#35)

0.6.0 announced downloads and served none of them. Every URL 404'd on the live board:

GET /candidates/<slug>/resume.md    404
GET /candidates/<slug>/resume.html  404
GET /candidates/<slug>/resume.docx  404
GET /candidates/<slug>/resume.pdf   404

Not a stale deploy: /.well-known/agenticjobs reported 0.6.0, the filter work shipped in the same release rendered correctly on the same pages, and the route was right there in the running source.

All four were registered as one route:

pages.get('/candidates/:slug/resume.:format{md|html|pdf|docx}', )

A literal prefix (resume.) in the same path segment as a regex-constrained parameter is a RegExpRouter feature. Hono's default is SmartRouter, which falls back to TrieRouter for the whole router the moment any one route is beyond RegExpRouter, and TrieRouter does not support that combination. On hono 4.13.7, asked for /candidates/ada/resume.md:

RegExpRouter  -> 1 handler
TrieRouter    -> 0 handlers

and on this repo's own router, with nothing mounted:

pageRoutes().router.match('GET', '/candidates/ada/resume.md')  -> 0 handlers
pageRoutes().router.match('GET', '/candidates/ada')            -> 1 handler

The handler was correct throughout. The URL simply reached nothing.

Four literal routes now, one per extension, delegating to one handler. The URLs are unchanged, so the buttons 0.6.0 put on the candidate page work as written.

Why it survived review, which is the part worth carrying: a small throwaway app written to reproduce this resolves to RegExpRouter and answers 200. The pattern only fails in a router large enough to have been pushed onto TrieRouter, so the obvious repro exonerates it.

test/routing.test.ts therefore asks pageRoutes().router directly rather than making a request: no database, no app, and no parent whose own router might differ. It pins the RegExpRouter/TrieRouter difference too, so if a future hono teaches TrieRouter this pattern the test says so.

Full changelog: v0.6.0...v0.6.1

0.6.0: downloads, and filters you can see

Choose a tag to compare

@ralyodio ralyodio released this 09 Sep 10:33
701ae6f

Download a resume as PDF, DOCX or Markdown (#28)

Every published resume is now a file you can hand to someone, from a Download card on the candidate page:

/candidates/<slug>/resume.md      the stored document, untouched
/candidates/<slug>/resume.html    the page, standalone
/candidates/<slug>/resume.docx    pandoc, straight from the Markdown
/candidates/<slug>/resume.pdf     weasyprint, from the board's own HTML and CSS

The PDF goes through the styled HTML rather than through pandoc's own PDF writer, so the file and the web page stay the same document instead of two designs maintained separately. An uploaded original wins over a regenerated one: if somebody handed the board a PDF, asking for a PDF gives that back rather than a PDF rebuilt from a parse of it. The runtime image gains weasyprint; a browser would render it too and would add about 350MB for one button.

Subscribe to what is on the screen, not to its tags (#29)

The filter strip only ever knew about tags, so narrowing to remote work changed the results and appeared nowhere: no chip naming it, and, because the strip was gated on there being a tag, no Clear and no Subscribe either.

Now every active filter is a removable chip and Subscribe carries the whole query:

/?tags=javascript&workplace=remote  ->  Subscribe = /feed?workplace=remote&tags=javascript

/feed had parsed the full query since #26 and then described itself by its tags alone, so every narrowing of one tag advertised the same rel=self. Also here: a tag clicked on a result card kept the rest of the search instead of starting a new one, and /candidates stopped offering the everything-feed as its Subscribe link.

Follow employers and candidates, and post updates (#30)

Follows and updates, with one additive migration (0010_updates_and_follows.sql, all create … if not exists).

The stats tag is a script, and the policy lets it run (#33)

#31 added the CrawlProof tag as Next.js's <Script> component. This is a Hono app rendering server-side JSX, next is not a dependency, and import Script from "next/script" fails tsc, which fails the build, which fails the image. master was red from #31 until this landed.

The quieter half: fixing the import alone would have left script-src 'self' in place, so the browser would have dropped the script from crawlproof.com and said so only in a console nobody has open. The stats would have read as an audience of nobody rather than as a bug. Both halves are fixed, and a test now renders the layout and checks the policy permits the host the page loads from.

Full changelog: v0.5.0...v0.6.0

0.5.0: one query, every representation

Choose a tag to compare

@ralyodio ralyodio released this 09 Sep 09:26
56e7bb5

A filter written once now works whichever way you read the board.

/?workplace=remote&tags=javascript
/api/v1/jobs?workplace=remote&tags=javascript
/jobs.md?workplace=remote&tags=javascript
/jobs.json?workplace=remote&tags=javascript
/feed?workplace=remote&tags=javascript
/jobs.rss?workplace=remote&tags=javascript
/feed.rss?workplace=remote&tags=javascript

search_jobs over MCP already took the whole filter set, and now accepts tags alongside tag so it spells the filter the way everything else does.

Two feeds were ignoring you

/jobs.rss and /feed.rss built their query from an empty parameter list, so whatever a reader put in the address was discarded and the feed answered with the whole board. A feed that looks filtered and is not is worse than one that refuses, because nothing tells the reader.

Asking each surface for workplace=onsite on a board whose only job is remote, before the fix: /api/v1/jobs 0, /jobs.json 0, /feed 0, /jobs.rss 1, /feed.rss 3.

Markdown

/jobs.md and /candidates.md, taking the same query. A model handed HTML has to pull a page apart to find the job; handed Markdown it has the document. Each listing carries its apply-schema URL, so an agent reading the Markdown does not have to guess how to apply. Both are linked from the page head and named in llms.txt.

What a filtered everything-feed contains

/feed.rss is still the whole site and is filterable now. Tags are the only filter that means the same thing on both halves of the board, so a job-shaped filter (workplace, employment type, seniority, agent policy, a salary floor, an employer, free text) drops the people: a person cannot answer "is this remote", and answering a narrow question with the whole roster is the same failure as ignoring the filter. Employers drop out of any filtered feed for the same reason.

121 tests against a real Postgres.

0.4.2: three things an audit found

Choose a tag to compare

@ralyodio ralyodio released this 09 Sep 09:13
e10443b

Three things found by auditing the board rather than by using it. No API is removed and nothing a caller depends on changes shape.

SECRET was read by no code

Not by sessions, magic links, device codes or webauthn challenges. All four are 256-bit random values stored as a SHA-256 hash in Postgres: there is no HMAC and nothing to key.

Two places said otherwise. The README called PUBLIC_URL and SECRET "the two variables that matter", and the boot log warned:

SECRET is unset, so one was generated. Sessions will not survive a restart.

That is false. Sessions are database rows and survive a restart whatever SECRET is set to. Anyone who read either one learned something untrue about their own deployment.

SECRET is gone from the config, the README, .env.example and the compose file. If you have it set, nothing happens: it was already being ignored.

The search box threw away your tag filter

A GET form submits its own fields and nothing else, and tags are set by clicking a badge rather than by a control. So on /?tags=javascript, searching or touching any dropdown silently dropped the tag. salaryMin and an employer filter had the same problem. All three are carried through now.

The published API document was missing a third of the API

34 routes served, 22 documented. The 13 missing included all of /candidates, POST /jobs/import, PATCH /jobs/{slug}, POST /auth/magic-link, and POST /orgs, which you have to call before you can post a job at all.

This board's whole pitch is that a person reads the page and an agent reads the JSON. An agent reading the index could not have found that candidates exist, that a job can be imported from a URL, or how to create the employer a listing needs.

There was already a test that every documented path resolves, so nothing in the document was a lie. Nothing checked the other direction, so anything added after the document was written never appeared in it. Both directions are checked now, and the new one reads the routes out of the router rather than a list kept beside it.

119 tests against a real Postgres.

0.4.1: the badges go somewhere, and the preview is readable

Choose a tag to compare

@ralyodio ralyodio released this 09 Sep 08:58
bbe8d6f

Two page fixes. No API change, no new command, no migration.

The resume preview is readable

It was in the sidebar on /me/resumes/<slug>, and the sidebar is 20rem. A resume rendered into 20rem is a column of two or three words per line, which tells you nothing about whether the document reads well, and that is the only reason to have a preview.

It is a full width card below the editor now. The warnings and the share link, which genuinely are short, stay beside the form.

The badges on a job go somewhere

remote, contract, the seniority and the agent policy were the last badges that were not links. Each one is a filter the board already supported.

They link to the filter that holds them rather than to a tag search:

remote    -> /?workplace=remote
contract  -> /?employmentType=contract
senior    -> /?seniority=senior
welcome   -> /?agentPolicy=welcome

remote is the workplace and not a tag, so tags=remote would have searched a list the word is not in and returned nothing on a job that plainly is remote.

They also narrow: clicking one while a tag filter is active keeps both. Paging resets, because page 3 of the old search is not page 3 of the new one.

115 tests against a real Postgres.