diff --git a/src/server/routes/discovery.ts b/src/server/routes/discovery.ts index dff1a3d..257490c 100644 --- a/src/server/routes/discovery.ts +++ b/src/server/routes/discovery.ts @@ -743,6 +743,41 @@ export function discoveryRoutes(): Hono { 'Resumes are Markdown in the OpenResume.md convention:', `${config.publicUrl}/docs/openresume`, '', + '## Being listed as a candidate', + '', + 'A profile here is a resume its owner published. There is no separate profile to fill', + 'in: the name, headline, location and skills on a directory row are read out of the', + 'Markdown, so a "## Skills" section is what makes somebody findable by skill.', + '', + 'Writing one needs the token from `agenticjobs login`; reading them does not.', + '', + `- POST ${config.publicUrl}/api/v1/resumes with {"markdown": "# Name\\n..."} saves one`, + `- PATCH ${config.publicUrl}/api/v1/resumes/{slug} with {"visibility": "public"} lists it`, + `- POST ${config.publicUrl}/api/v1/resumes/import converts a pdf, docx or txt upload`, + '', + 'Visibility is private (the default), link (an address, unlisted) or public (listed at', + '/candidates). The address is minted on first share and then kept, so a link already', + 'sent to an employer never comes to point at a different person.', + '', + '## Posting a job', + '', + 'Both steps need the token from `agenticjobs login`, and an employer comes first.', + '', + `- POST ${config.publicUrl}/api/v1/orgs with {"name": "Example Works"}, once`, + `- POST ${config.publicUrl}/api/v1/jobs with {"org": "", "title", "description",`, + ' "agentPolicy", and whatever else the listing needs', + `- POST ${config.publicUrl}/api/v1/jobs/{slug}/publish takes a draft live`, + '', + 'A listing arrives as a DRAFT unless you send "publish": true. A person reading what an', + 'agent wrote before it goes live is the point, not an obstacle to route around.', + '', + 'Every listing carries an agentPolicy, and leaving it out means "disclose" rather than', + 'meaning nothing. Applications are taken on this board: a listing that links out to a', + 'form somewhere else is refused, with that as the reason.', + '', + `If the job already lives on a careers page, POST ${config.publicUrl}/api/v1/jobs/import`, + 'with {"url": "..."} and check the draft it leaves.', + '', '## MCP', '', `${config.publicUrl}/api/mcp (streamable HTTP)`, diff --git a/src/views/docs.tsx b/src/views/docs.tsx index 82c451e..69bb344 100644 --- a/src/views/docs.tsx +++ b/src/views/docs.tsx @@ -119,6 +119,158 @@ Chief Programmer (1842 - 1843)

+ +
+

+ A candidate profile +

+

+ A profile here is a resume you chose to share. There is no second form restating the + document you already wrote. +

+
+
+        {`# 1. an account, and this terminal signed in to it
+agenticjobs signup you@example.com
+
+# 2. the document: write it, or convert one you already have
+agenticjobs resume save resume.md --title "Backend engineer"
+agenticjobs resume import ~/cv.pdf     # pdf, docx, txt or md
+
+# 3. list it, which is a separate decision from saving it
+curl -X PATCH ${publicUrl}/api/v1/resumes/SLUG \\
+  -H "authorization: Bearer $TOKEN" \\
+  -H 'content-type: application/json' \\
+  -d '{"visibility": "public"}'`}
+      
+

+ Three visibilities. private is the default and is yours alone;{' '} + link gives it an address you can send to one employer without it appearing + anywhere; public lists it at /candidates. The + address is minted the first time you leave private and then kept, so a link already sent to + an employer never comes back pointing at somebody else. +

+

+ The directory row is read out of the Markdown. The # heading + is your name, the first plain line before any ## section is your headline, a{' '} + - **Location**: bullet is what location filters match, and the bullets under{' '} + ## Skills become the tags people browse by. Comma-separated skills on one line + are split, so Languages: Go, TypeScript is two tags rather than one. If you + want to be found by a skill, the section has to be there. +

+

+ Your contact details are withheld from anonymous readers. Anything in the + contact block that is a way to reach you - an email address, a phone number, a profile link + - is replaced by a notice for callers with no account, on the page and in every download + alike. Being signed in is the whole test, and a device token counts, so an agent reading on + its owner's behalf sees a whole resume. Location stays either way, because the directory + filters on it. +

+
+        {`${publicUrl}/candidates/SLUG              # the page
+${publicUrl}/candidates/SLUG/resume.md    # .md, .html, .pdf, .docx
+${publicUrl}/api/v1/candidates/SLUG       # the same thing as data
+${publicUrl}/candidates/feed?tags=go,postgres`}
+      
+

+ In a browser instead: /me/resumes/new writes the template for + you and takes the upload. The token above is the one agenticjobs login saved + in ~/.config/agenticjobs/config.json. +

+
+ + +
+

+ Post a job +

+

+ An employer first, then listings under it. Every listing arrives as a draft, including + the ones an agent posts. +

+
+
+        {`# 1. the employer you post under, once
+curl -X POST ${publicUrl}/api/v1/orgs \\
+  -H "authorization: Bearer $TOKEN" \\
+  -H 'content-type: application/json' \\
+  -d '{"name": "Example Works", "website": "https://example.com"}'
+
+# 2. the listing
+agenticjobs post job.md --org example-works
+agenticjobs publish SLUG        # after a person has read it`}
+      
+

+ A job is a Markdown file with front matter: the structured fields above the rule, the + description below it. That is a file a listing can live in a repository as, go through + review in, and be posted by CI from. +

+ --- +org: example-works +title: Senior Go Engineer +employment_type: full-time +workplace: remote +seniority: senior +location: Berlin +remote_regions: [EU, UK] +salary_min: 90000 +salary_max: 130000 +salary_currency: EUR +salary_period: year +agent_policy: welcome +tags: [go, postgres] +stack: [Go, Postgres, Kubernetes] +requirements: + - Five years writing services in Go. + - You have run what you built. +--- + +## About the role + +What the work actually is, in your own words. +`} + /> +

+ employment_type is full-time, part-time, contract, internship or temporary;{' '} + workplace is remote, hybrid or onsite; salary_period runs from + hour to year, and salary_unpaid: true says so plainly instead of leaving a + range at zero. Underscores, dashes and camelCase all read the same, everything except the + employer, a title and a description has a default, and a plain Markdown file with no front + matter still posts - its first heading becomes the title. +

+

+ + agent_policy is the field this board exists for. + {' '} + welcome, disclose or human-only, and it defaults to{' '} + disclose. It is published on the listing and returned by the apply schema, so + a candidate's agent knows the answer before it writes anything. human-only is + stated rather than enforced: no board can tell who wrote a cover letter, and pretending + otherwise only teaches the next candidate to lie. +

+

+ Applications are taken here. A listing that points at a form somewhere else + is a link to a job rather than a job, and is refused with that reason rather than quietly + rewritten. If the job already lives on your own careers page, import it instead:{' '} + agenticjobs new https://example.com/careers/123 reads the page, takes its + JobPosting data if it publishes any, and leaves a draft for you to check.{' '} + agenticjobs update <url> re-reads it later into the same listing. +

+
+        {`agenticjobs applications SLUG    # what came in, each with its id
+agenticjobs decide ID hired      # reviewing, rejected or hired
+agenticjobs edit SLUG job.md     # rewrite it, keeping its URL
+agenticjobs close SLUG`}
+      
+

+ The same two steps in a browser: /me/employers/new, then{' '} + /post. Or in one request: POST /api/v1/jobs with an{' '} + org slug and "publish": true when you have already read what you + are posting. +

+
+

Install it

@@ -172,8 +324,9 @@ agenticjobs submit agenticjobs tui`}

- Hiring works from the same account: agenticjobs post job.md --org acme, then{' '} - agenticjobs publish when you have read it. + Hiring works from the same account, and one account is both sides: see{' '} + Post a job above. Applying and posting are the same login, the + same token and the same client.

diff --git a/test/views.test.ts b/test/views.test.ts index e200ab4..cf5f86c 100644 --- a/test/views.test.ts +++ b/test/views.test.ts @@ -10,6 +10,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { ResumeEditor } from '../dist/views/me.js'; import { PostJobPage } from '../dist/views/post.js'; +import { DocsPage } from '../dist/views/docs.js'; const resume = { id: 'r', @@ -326,3 +327,45 @@ test('the post form can say a role is unpaid', () => { `a rejected form comes back with the box still ticked, got ${box(ticked)}`, ); }); + +/** + * The two things a reader arrives at /docs wanting to do. + * + * Reading and applying were documented from the first day because they are + * what the board was built to show off. Getting listed and hiring were not, + * and the page read as though the board were only half usable. These assert + * the step that is actually easy to leave out of each: publishing a resume is + * a decision separate from saving one, and posting a job needs an employer + * before it needs a listing. + */ +const docs = () => + String( + DocsPage({ + publicUrl: 'https://example.test', + boardName: 'Example Board', + isDirectory: true, + }), + ); + +test('the docs say how to get listed as a candidate, not only how to apply', () => { + const html = docs(); + assert.match(html, /\/api\/v1\/resumes/, 'the endpoint that saves a resume'); + assert.match(html, /visibility/, 'listing it is a separate decision, so it has to be named'); + assert.match(html, /"public"|"public"/, 'and the value that lists it'); + assert.match(html, /\/candidates/, 'where a listed resume ends up'); + // Skills are the tags people browse by, and a resume with no such section + // is invisible to every one of those links. Documented or nobody knows. + assert.match(html, /## Skills/); +}); + +test('the docs say how to post a job, employer first', () => { + const html = docs(); + const orgs = html.indexOf('/api/v1/orgs'); + const post = html.indexOf('agenticjobs post job.md'); + assert.ok(orgs !== -1, 'creating the employer has to be on the page'); + assert.ok(post !== -1, 'and so does posting the listing'); + assert.ok(orgs < post, 'in that order: a listing has nowhere to go without an employer'); + assert.match(html, /agent_policy|agentPolicy/, 'the field this board exists for'); + assert.match(html, /draft/i, 'a posted job is a draft until a person publishes it'); + assert.match(html, /agenticjobs publish/); +});