Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/server/routes/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,41 @@ export function discoveryRoutes(): Hono<AppEnv> {
'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": "<employer-slug>", "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)`,
Expand Down
157 changes: 155 additions & 2 deletions src/views/docs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,158 @@ Chief Programmer (1842 - 1843)
</p>
</Card>

<Card>
<div class="card-header">
<h2 class="card-title" id="candidate-profile">
A candidate profile
</h2>
<p class="card-description">
A profile here is a resume you chose to share. There is no second form restating the
document you already wrote.
</p>
</div>
<pre class="code-block">
{`# 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"}'`}
</pre>
<p class="small">
Three visibilities. <code>private</code> is the default and is yours alone;{' '}
<code>link</code> gives it an address you can send to one employer without it appearing
anywhere; <code>public</code> lists it at <a href="/candidates">/candidates</a>. 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.
</p>
<p class="small">
<strong>The directory row is read out of the Markdown.</strong> The <code>#</code> heading
is your name, the first plain line before any <code>##</code> section is your headline, a{' '}
<code>- **Location**:</code> bullet is what location filters match, and the bullets under{' '}
<code>## Skills</code> become the tags people browse by. Comma-separated skills on one line
are split, so <code>Languages: Go, TypeScript</code> is two tags rather than one. If you
want to be found by a skill, the section has to be there.
</p>
<p class="small">
<strong>Your contact details are withheld from anonymous readers.</strong> 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.
</p>
<pre class="code-block">
{`${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`}
</pre>
<p class="small muted">
In a browser instead: <a href="/me/resumes/new">/me/resumes/new</a> writes the template for
you and takes the upload. The token above is the one <code>agenticjobs login</code> saved
in <code>~/.config/agenticjobs/config.json</code>.
</p>
</Card>

<Card>
<div class="card-header">
<h2 class="card-title" id="post-a-job">
Post a job
</h2>
<p class="card-description">
An employer first, then listings under it. Every listing arrives as a draft, including
the ones an agent posts.
</p>
</div>
<pre class="code-block">
{`# 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`}
</pre>
<p class="small">
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.
</p>
<Prose
html={`<pre><code>---
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.
</code></pre>`}
/>
<p class="small">
<code>employment_type</code> is full-time, part-time, contract, internship or temporary;{' '}
<code>workplace</code> is remote, hybrid or onsite; <code>salary_period</code> runs from
hour to year, and <code>salary_unpaid: true</code> 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.
</p>
<p class="small">
<strong>
<code>agent_policy</code> is the field this board exists for.
</strong>{' '}
<code>welcome</code>, <code>disclose</code> or <code>human-only</code>, and it defaults to{' '}
<code>disclose</code>. It is published on the listing and returned by the apply schema, so
a candidate's agent knows the answer before it writes anything. <code>human-only</code> is
stated rather than enforced: no board can tell who wrote a cover letter, and pretending
otherwise only teaches the next candidate to lie.
</p>
<p class="small">
<strong>Applications are taken here.</strong> 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:{' '}
<code>agenticjobs new https://example.com/careers/123</code> reads the page, takes its
JobPosting data if it publishes any, and leaves a draft for you to check.{' '}
<code>agenticjobs update &lt;url&gt;</code> re-reads it later into the same listing.
</p>
<pre class="code-block">
{`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`}
</pre>
<p class="small muted">
The same two steps in a browser: <a href="/me/employers/new">/me/employers/new</a>, then{' '}
<a href="/post">/post</a>. Or in one request: <code>POST /api/v1/jobs</code> with an{' '}
<code>org</code> slug and <code>"publish": true</code> when you have already read what you
are posting.
</p>
</Card>

<Card>
<div class="card-header">
<h2 class="card-title">Install it</h2>
Expand Down Expand Up @@ -172,8 +324,9 @@ agenticjobs submit <id>
agenticjobs tui`}
</pre>
<p class="small muted">
Hiring works from the same account: <code>agenticjobs post job.md --org acme</code>, then{' '}
<code>agenticjobs publish</code> when you have read it.
Hiring works from the same account, and one account is both sides: see{' '}
<a href="#post-a-job">Post a job</a> above. Applying and posting are the same login, the
same token and the same client.
</p>
</Card>

Expand Down
43 changes: 43 additions & 0 deletions test/views.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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, /&quot;public&quot;|"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/);
});
Loading