@profullstack/agenticjobs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +219 -0
- package/bin/agenticjobs-mcp.mjs +2 -0
- package/bin/agenticjobs.mjs +2 -0
- package/dist/cli/args.d.ts +17 -0
- package/dist/cli/args.js +103 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/format.d.ts +15 -0
- package/dist/cli/format.js +24 -0
- package/dist/cli/format.js.map +1 -0
- package/dist/cli/index.d.ts +13 -0
- package/dist/cli/index.js +620 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/jobfile.d.ts +15 -0
- package/dist/cli/jobfile.js +94 -0
- package/dist/cli/jobfile.js.map +1 -0
- package/dist/client/client.d.ts +105 -0
- package/dist/client/client.js +182 -0
- package/dist/client/client.js.map +1 -0
- package/dist/client/config.d.ts +33 -0
- package/dist/client/config.js +92 -0
- package/dist/client/config.js.map +1 -0
- package/dist/client/fanout.d.ts +35 -0
- package/dist/client/fanout.js +77 -0
- package/dist/client/fanout.js.map +1 -0
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.js +5 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/login.d.ts +23 -0
- package/dist/client/login.js +45 -0
- package/dist/client/login.js.map +1 -0
- package/dist/config.d.ts +40 -0
- package/dist/config.js +64 -0
- package/dist/config.js.map +1 -0
- package/dist/core/applications.d.ts +67 -0
- package/dist/core/applications.js +187 -0
- package/dist/core/applications.js.map +1 -0
- package/dist/core/auth.d.ts +120 -0
- package/dist/core/auth.js +246 -0
- package/dist/core/auth.js.map +1 -0
- package/dist/core/import.d.ts +52 -0
- package/dist/core/import.js +311 -0
- package/dist/core/import.js.map +1 -0
- package/dist/core/jobs.d.ts +91 -0
- package/dist/core/jobs.js +381 -0
- package/dist/core/jobs.js.map +1 -0
- package/dist/core/orgs.d.ts +21 -0
- package/dist/core/orgs.js +111 -0
- package/dist/core/orgs.js.map +1 -0
- package/dist/core/resumes.d.ts +51 -0
- package/dist/core/resumes.js +144 -0
- package/dist/core/resumes.js.map +1 -0
- package/dist/core/zip.d.ts +17 -0
- package/dist/core/zip.js +77 -0
- package/dist/core/zip.js.map +1 -0
- package/dist/db/index.d.ts +3 -0
- package/dist/db/index.js +3 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/migrate.d.ts +15 -0
- package/dist/db/migrate.js +67 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/pool.d.ts +13 -0
- package/dist/db/pool.js +68 -0
- package/dist/db/pool.js.map +1 -0
- package/dist/db/seed.d.ts +9 -0
- package/dist/db/seed.js +120 -0
- package/dist/db/seed.js.map +1 -0
- package/dist/directory/announce.d.ts +32 -0
- package/dist/directory/announce.js +59 -0
- package/dist/directory/announce.js.map +1 -0
- package/dist/directory/federate.d.ts +59 -0
- package/dist/directory/federate.js +126 -0
- package/dist/directory/federate.js.map +1 -0
- package/dist/directory/fetch.d.ts +35 -0
- package/dist/directory/fetch.js +138 -0
- package/dist/directory/fetch.js.map +1 -0
- package/dist/directory/index.d.ts +4 -0
- package/dist/directory/index.js +5 -0
- package/dist/directory/index.js.map +1 -0
- package/dist/directory/registry.d.ts +55 -0
- package/dist/directory/registry.js +155 -0
- package/dist/directory/registry.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/markup/escape.d.ts +20 -0
- package/dist/markup/escape.js +56 -0
- package/dist/markup/escape.js.map +1 -0
- package/dist/markup/markdown.d.ts +35 -0
- package/dist/markup/markdown.js +279 -0
- package/dist/markup/markdown.js.map +1 -0
- package/dist/markup/resume.d.ts +72 -0
- package/dist/markup/resume.js +260 -0
- package/dist/markup/resume.js.map +1 -0
- package/dist/mcp/protocol.d.ts +56 -0
- package/dist/mcp/protocol.js +48 -0
- package/dist/mcp/protocol.js.map +1 -0
- package/dist/mcp/server.d.ts +21 -0
- package/dist/mcp/server.js +73 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/stdio.d.ts +13 -0
- package/dist/mcp/stdio.js +85 -0
- package/dist/mcp/stdio.js.map +1 -0
- package/dist/mcp/tools.d.ts +26 -0
- package/dist/mcp/tools.js +351 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/schema/index.d.ts +5 -0
- package/dist/schema/index.js +6 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/schema/instance.d.ts +85 -0
- package/dist/schema/instance.js +147 -0
- package/dist/schema/instance.js.map +1 -0
- package/dist/schema/job.d.ts +151 -0
- package/dist/schema/job.js +73 -0
- package/dist/schema/job.js.map +1 -0
- package/dist/schema/jsonld.d.ts +18 -0
- package/dist/schema/jsonld.js +120 -0
- package/dist/schema/jsonld.js.map +1 -0
- package/dist/schema/query.d.ts +47 -0
- package/dist/schema/query.js +103 -0
- package/dist/schema/query.js.map +1 -0
- package/dist/schema/text.d.ts +27 -0
- package/dist/schema/text.js +117 -0
- package/dist/schema/text.js.map +1 -0
- package/dist/server/app.d.ts +13 -0
- package/dist/server/app.js +57 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/deps.d.ts +20 -0
- package/dist/server/deps.js +2 -0
- package/dist/server/deps.js.map +1 -0
- package/dist/server/middleware.d.ts +39 -0
- package/dist/server/middleware.js +100 -0
- package/dist/server/middleware.js.map +1 -0
- package/dist/server/routes/api.d.ts +20 -0
- package/dist/server/routes/api.js +584 -0
- package/dist/server/routes/api.js.map +1 -0
- package/dist/server/routes/descriptor.d.ts +11 -0
- package/dist/server/routes/descriptor.js +31 -0
- package/dist/server/routes/descriptor.js.map +1 -0
- package/dist/server/routes/discovery.d.ts +12 -0
- package/dist/server/routes/discovery.js +305 -0
- package/dist/server/routes/discovery.js.map +1 -0
- package/dist/server/routes/mcp.d.ts +19 -0
- package/dist/server/routes/mcp.js +85 -0
- package/dist/server/routes/mcp.js.map +1 -0
- package/dist/server/routes/openapi.d.ts +11 -0
- package/dist/server/routes/openapi.js +297 -0
- package/dist/server/routes/openapi.js.map +1 -0
- package/dist/server/routes/pages.d.ts +11 -0
- package/dist/server/routes/pages.js +445 -0
- package/dist/server/routes/pages.js.map +1 -0
- package/dist/server/routes/passkey.d.ts +20 -0
- package/dist/server/routes/passkey.js +198 -0
- package/dist/server/routes/passkey.js.map +1 -0
- package/dist/server/routes/specs.d.ts +10 -0
- package/dist/server/routes/specs.js +42 -0
- package/dist/server/routes/specs.js.map +1 -0
- package/dist/server/serve.d.ts +11 -0
- package/dist/server/serve.js +79 -0
- package/dist/server/serve.js.map +1 -0
- package/dist/tui/index.d.ts +13 -0
- package/dist/tui/index.js +266 -0
- package/dist/tui/index.js.map +1 -0
- package/dist/tui/state.d.ts +63 -0
- package/dist/tui/state.js +136 -0
- package/dist/tui/state.js.map +1 -0
- package/dist/tui/types.d.ts +2 -0
- package/dist/tui/types.js +2 -0
- package/dist/tui/types.js.map +1 -0
- package/dist/tui/views.d.ts +11 -0
- package/dist/tui/views.js +197 -0
- package/dist/tui/views.js.map +1 -0
- package/dist/views/auth.d.ts +21 -0
- package/dist/views/auth.js +5 -0
- package/dist/views/auth.js.map +1 -0
- package/dist/views/docs.d.ts +17 -0
- package/dist/views/docs.js +40 -0
- package/dist/views/docs.js.map +1 -0
- package/dist/views/jobs.d.ts +49 -0
- package/dist/views/jobs.js +44 -0
- package/dist/views/jobs.js.map +1 -0
- package/dist/views/layout.d.ts +58 -0
- package/dist/views/layout.js +36 -0
- package/dist/views/layout.js.map +1 -0
- package/dist/views/me.d.ts +27 -0
- package/dist/views/me.js +6 -0
- package/dist/views/me.js.map +1 -0
- package/dist/views/network.d.ts +21 -0
- package/dist/views/network.js +17 -0
- package/dist/views/network.js.map +1 -0
- package/dist/views/post.d.ts +22 -0
- package/dist/views/post.js +20 -0
- package/dist/views/post.js.map +1 -0
- package/docs/openjob.md +200 -0
- package/docs/openresume.md +143 -0
- package/migrations/0001_init.sql +109 -0
- package/migrations/0002_auth.sql +82 -0
- package/migrations/0003_directory.sql +27 -0
- package/migrations/0004_resumes.sql +63 -0
- package/migrations/0005_application_drafts.sql +20 -0
- package/package.json +92 -0
- package/web/public/app.css +731 -0
- package/web/public/app.js +173 -0
- package/web/public/icon.svg +5 -0
- package/web/public/sw.js +64 -0
- package/web/public/tokens.css +153 -0
package/docs/openjob.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# OpenJob
|
|
2
|
+
|
|
3
|
+
A job posting that an agent can read, and that says out loud whether an agent may
|
|
4
|
+
answer it.
|
|
5
|
+
|
|
6
|
+
Job postings already have a machine-readable format: schema.org `JobPosting`, which
|
|
7
|
+
search engines index and which most boards emit. OpenJob does not replace it. It
|
|
8
|
+
extends it with the three things `JobPosting` has no vocabulary for, and which the
|
|
9
|
+
next few years of hiring depend on:
|
|
10
|
+
|
|
11
|
+
1. **whether the employer accepts applications written with an agent**, stated
|
|
12
|
+
rather than discovered by silent rejection;
|
|
13
|
+
2. **the application form, as data**, so applying does not require rendering a page;
|
|
14
|
+
3. **a description in Markdown**, so the same text is legible to a person, a model
|
|
15
|
+
and a terminal without a HTML-to-text round trip.
|
|
16
|
+
|
|
17
|
+
## The document
|
|
18
|
+
|
|
19
|
+
A listing is a JSON object. Every field below is what the reference implementation
|
|
20
|
+
serves at `GET /api/v1/jobs/{slug}`.
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"id": "b7c3...",
|
|
25
|
+
"slug": "staff-engineer-agent-platform",
|
|
26
|
+
"title": "Staff Engineer, Agent Platform",
|
|
27
|
+
"description": "We are building the plumbing...",
|
|
28
|
+
"org": {
|
|
29
|
+
"slug": "example-works",
|
|
30
|
+
"name": "Example Works",
|
|
31
|
+
"website": "https://example.com"
|
|
32
|
+
},
|
|
33
|
+
"employmentType": "full-time",
|
|
34
|
+
"workplace": "remote",
|
|
35
|
+
"seniority": "staff",
|
|
36
|
+
"location": "European timezones",
|
|
37
|
+
"remoteRegions": ["DE", "NL", "PT"],
|
|
38
|
+
"salary": {
|
|
39
|
+
"min": 180000,
|
|
40
|
+
"max": 230000,
|
|
41
|
+
"currency": "USD",
|
|
42
|
+
"period": "year",
|
|
43
|
+
"equity": "0.1% - 0.4%"
|
|
44
|
+
},
|
|
45
|
+
"tags": ["infrastructure", "agents"],
|
|
46
|
+
"stack": ["typescript", "postgres", "rust"],
|
|
47
|
+
"requirements": ["Has run something other people depended on."],
|
|
48
|
+
"responsibilities": ["Own the execution layer."],
|
|
49
|
+
"agentPolicy": "welcome",
|
|
50
|
+
"apply": { "via": "board", "schema": { "fields": [] } },
|
|
51
|
+
"status": "published",
|
|
52
|
+
"publishedAt": "2026-09-08T10:00:00.000Z",
|
|
53
|
+
"expiresAt": null
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`description` is Markdown. Not HTML, and not plain text with the formatting removed.
|
|
58
|
+
|
|
59
|
+
## agentPolicy
|
|
60
|
+
|
|
61
|
+
The field this format exists for. Required on every listing, with exactly three
|
|
62
|
+
values:
|
|
63
|
+
|
|
64
|
+
| Value | Means |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `welcome` | Agent-written applications are fine. Nothing is asked. |
|
|
67
|
+
| `disclose` | Fine, but say so. The application carries a structured disclosure. |
|
|
68
|
+
| `human-only` | The employer is asking for something a person wrote. |
|
|
69
|
+
|
|
70
|
+
Three things about it are deliberate.
|
|
71
|
+
|
|
72
|
+
**It is required.** An optional field would be omitted by most posters, and "not
|
|
73
|
+
stated" is exactly the ambiguity candidates are navigating today by guessing.
|
|
74
|
+
|
|
75
|
+
**`human-only` is a request, not a control.** No board can tell who wrote a cover
|
|
76
|
+
letter, and one that claims it can is selling something. Stating it plainly is worth
|
|
77
|
+
more than pretending to enforce it: a candidate who reads `human-only` and writes it
|
|
78
|
+
themselves has been told what the employer wants, which is all anyone can offer.
|
|
79
|
+
|
|
80
|
+
**Disclosure is not evidence against the candidate.** A board that collects the
|
|
81
|
+
disclosure and then filters those applications out has broken the field for
|
|
82
|
+
everybody, because the next candidate learns to lie. `disclose` means the employer
|
|
83
|
+
wants to know, and wanting to know is the reason to answer honestly.
|
|
84
|
+
|
|
85
|
+
## apply
|
|
86
|
+
|
|
87
|
+
Three shapes, and only the first is completable without a browser.
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{ "via": "board", "schema": { "fields": [ ... ] } }
|
|
91
|
+
{ "via": "url", "url": "https://example.com/careers/123" }
|
|
92
|
+
{ "via": "email", "email": "jobs@example.com" }
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A listing that says `url` or `email` is being honest that an agent cannot finish the
|
|
96
|
+
job. That is better than a board pretending every listing is applicable and handing
|
|
97
|
+
an agent a form it cannot post.
|
|
98
|
+
|
|
99
|
+
### The application schema
|
|
100
|
+
|
|
101
|
+
A deliberately small subset of JSON Schema: small enough to render as an HTML form,
|
|
102
|
+
small enough for a model to fill in without a validator.
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"fields": [
|
|
107
|
+
{ "name": "name", "label": "Your name", "type": "text", "required": true, "maxLength": 120 },
|
|
108
|
+
{ "name": "email", "label": "Email", "type": "email", "required": true, "maxLength": 200 },
|
|
109
|
+
{ "name": "cover", "label": "Why you", "type": "textarea", "required": true, "maxLength": 5000,
|
|
110
|
+
"help": "Plain text. Short is fine." }
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`type` is one of `text`, `textarea`, `email`, `url`, `select` or `file`. A `select`
|
|
116
|
+
carries `options`.
|
|
117
|
+
|
|
118
|
+
An implementation publishes the schema at a stable address alongside the endpoint
|
|
119
|
+
that accepts it, so reading and answering are two requests and no guessing:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
GET /api/v1/jobs/{slug}/apply-schema
|
|
123
|
+
POST /api/v1/jobs/{slug}/apply
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### The disclosure
|
|
127
|
+
|
|
128
|
+
Posted alongside the answers:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"agent": { "name": "claude-opus-5 via agenticjobs-mcp", "supervised": true }
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`supervised` means a person read it before it was sent. It is never inferred - a
|
|
137
|
+
guessed disclosure is worthless in both directions.
|
|
138
|
+
|
|
139
|
+
### Resumes
|
|
140
|
+
|
|
141
|
+
A resume travels as Markdown, in the [OpenResume.md](./openresume.md) convention, in
|
|
142
|
+
a `resume` field. Not a file upload, not a URL to a PDF. An employer receives text
|
|
143
|
+
they can read and an agent receives text it can write.
|
|
144
|
+
|
|
145
|
+
## Mapping onto schema.org
|
|
146
|
+
|
|
147
|
+
An OpenJob listing maps cleanly onto `JobPosting`, and an implementation should emit
|
|
148
|
+
both - the JSON-LD for search engines, the OpenJob document for everything else.
|
|
149
|
+
|
|
150
|
+
| OpenJob | JobPosting |
|
|
151
|
+
| --- | --- |
|
|
152
|
+
| `title` | `title` |
|
|
153
|
+
| `description` | `description` |
|
|
154
|
+
| `org` | `hiringOrganization` |
|
|
155
|
+
| `employmentType` | `employmentType` (`FULL_TIME`, `PART_TIME`, `CONTRACTOR`, `INTERN`, `TEMPORARY`) |
|
|
156
|
+
| `workplace: "remote"` | `jobLocationType: "TELECOMMUTE"` |
|
|
157
|
+
| `remoteRegions` | `applicantLocationRequirements` |
|
|
158
|
+
| `location` | `jobLocation` |
|
|
159
|
+
| `salary` | `baseSalary`, plus an annualised `estimatedSalary` |
|
|
160
|
+
| `expiresAt` | `validThrough` |
|
|
161
|
+
| `apply.via === "board"` | `directApply: true` |
|
|
162
|
+
|
|
163
|
+
The three fields with no equivalent - `agentPolicy`, `applyVia` and the address of
|
|
164
|
+
the application schema - travel in `additionalProperty`, which is the vocabulary's
|
|
165
|
+
own escape hatch and passes every validator.
|
|
166
|
+
|
|
167
|
+
One trap worth naming, because it catches almost everyone: a remote role needs
|
|
168
|
+
`jobLocationType: "TELECOMMUTE"` **and** a location the hire may sit in. A remote
|
|
169
|
+
posting with no `jobLocation` and no `applicantLocationRequirements` fails Google's
|
|
170
|
+
validation while looking entirely correct.
|
|
171
|
+
|
|
172
|
+
## Drafts
|
|
173
|
+
|
|
174
|
+
A listing has a `status`, and `draft` is the interesting one. A draft is not
|
|
175
|
+
published, not in the feed, not in the API's search results, and not visible to any
|
|
176
|
+
other instance.
|
|
177
|
+
|
|
178
|
+
This is the employer's human control point. An agent can write the listing; a person
|
|
179
|
+
publishes it. The reference implementation makes a job created over the API or by a
|
|
180
|
+
tool call a draft *unless the caller explicitly asks otherwise*, which is the correct
|
|
181
|
+
default the first time an agent posts a job its author has not read.
|
|
182
|
+
|
|
183
|
+
The candidate's side of that seam is an application that can be prepared and held
|
|
184
|
+
until a person sends it. Both ends of the transaction have one, or the design is
|
|
185
|
+
lopsided.
|
|
186
|
+
|
|
187
|
+
## Federation
|
|
188
|
+
|
|
189
|
+
An implementation that wants to be discoverable serves a descriptor at
|
|
190
|
+
`/.well-known/agenticjobs` naming its search endpoint, its OpenAPI document, its MCP
|
|
191
|
+
endpoint and its feed. A directory reads that descriptor from the instance itself
|
|
192
|
+
rather than trusting an announcement, and a client fans one query out across every
|
|
193
|
+
instance it knows.
|
|
194
|
+
|
|
195
|
+
Nothing about OpenJob requires federation, and nothing about it requires a directory.
|
|
196
|
+
A single self-hosted board that serves these documents is a complete implementation.
|
|
197
|
+
|
|
198
|
+
## Implementations
|
|
199
|
+
|
|
200
|
+
- `agenticjobs` - MIT, https://github.com/profullstack/agenticjobs
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# OpenResume.md
|
|
2
|
+
|
|
3
|
+
A resume is a Markdown file. That is the whole idea.
|
|
4
|
+
|
|
5
|
+
Not a form, not a PDF, not a proprietary JSON schema that one product understands.
|
|
6
|
+
A file a person can read, diff, keep in a repository, paste into any tool, and hand
|
|
7
|
+
to an employer without losing anything on the way. And a file an agent can write and
|
|
8
|
+
revise without being taught a schema first.
|
|
9
|
+
|
|
10
|
+
This document describes the convention. It is deliberately thin, because a resume
|
|
11
|
+
that fails to parse still has to be a usable resume.
|
|
12
|
+
|
|
13
|
+
## The shape
|
|
14
|
+
|
|
15
|
+
```markdown
|
|
16
|
+
# Ada Lovelace
|
|
17
|
+
|
|
18
|
+
- **Email**: ada@example.com
|
|
19
|
+
- **Location**: London
|
|
20
|
+
- **Web**: https://example.com
|
|
21
|
+
|
|
22
|
+
Mathematician, looking for work on machines that do not exist yet.
|
|
23
|
+
|
|
24
|
+
## Experience
|
|
25
|
+
|
|
26
|
+
### Analytical Engine | London
|
|
27
|
+
Chief Programmer (1842 - 1843)
|
|
28
|
+
|
|
29
|
+
- Wrote the first published algorithm intended to be carried out by a machine.
|
|
30
|
+
- Described what the engine could do beyond arithmetic, which its designer had not.
|
|
31
|
+
|
|
32
|
+
## Skills
|
|
33
|
+
|
|
34
|
+
- Languages: analytical notation, French
|
|
35
|
+
- Tools: difference engine, correspondence
|
|
36
|
+
|
|
37
|
+
## Education
|
|
38
|
+
|
|
39
|
+
### University of London
|
|
40
|
+
Private tuition in mathematics (1840)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## The rules
|
|
44
|
+
|
|
45
|
+
There are six, and every one of them degrades rather than fails.
|
|
46
|
+
|
|
47
|
+
**1. One `#` heading, and it is the person's name.** A document with more than one
|
|
48
|
+
is read using the first; a document with none still parses, and a reader that wants a
|
|
49
|
+
name can say it does not have one.
|
|
50
|
+
|
|
51
|
+
**2. The bullet list directly under the name is the contact block.** Each item is
|
|
52
|
+
`Key: value`, with or without `**bold**` on the key, or a bare `[label](url)`. Values
|
|
53
|
+
that look like an email address, a phone number or a URL become links; anything else
|
|
54
|
+
stays text. Unknown keys are kept as written - `Pronouns`, `Timezone` and
|
|
55
|
+
`Availability` all work without anyone having to add them to a list.
|
|
56
|
+
|
|
57
|
+
**3. A single prose line between the contact block and the first `##` is a
|
|
58
|
+
headline.** One line. More than one, and only the first is treated that way.
|
|
59
|
+
|
|
60
|
+
**4. `##` opens a section.** The text is kept verbatim, and separately normalised for
|
|
61
|
+
matching, so `Work Experience`, `Experience` and `Employment` are one thing to a
|
|
62
|
+
filter and three different words on the page. The normalised names in common use are
|
|
63
|
+
`experience`, `education`, `skills`, `projects`, `summary`, `links`,
|
|
64
|
+
`certifications`, `languages`, `publications` and `awards`. A section whose name
|
|
65
|
+
matches none of them keeps its own name and is not dropped.
|
|
66
|
+
|
|
67
|
+
**5. `###` opens an entry within a section: a job, a degree, a project.** A `|` in
|
|
68
|
+
the heading splits it into a title and a place - `Company | Berlin`. The first
|
|
69
|
+
non-empty line under the heading is the entry's subtitle: a role, a degree, a
|
|
70
|
+
one-line description. If that line ends in a bracketed range, the range is read out
|
|
71
|
+
of it:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
Chief Programmer (1842 - 1843)
|
|
75
|
+
Staff Engineer (Mar 2020 - Present)
|
|
76
|
+
Contractor (2019 to 2021)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Hyphens, en dashes and em dashes are all accepted, because all three appear in real
|
|
80
|
+
resumes. `Present`, `Now`, `Current` and `Ongoing` mark a current role. Dates are
|
|
81
|
+
kept as the strings they were written as, and never reformatted: `Mar 2020` and
|
|
82
|
+
`03/2020` both survive, and a reader that wants a date can parse one.
|
|
83
|
+
|
|
84
|
+
**6. Bullets under an entry are its highlights.** Everything else under the entry is
|
|
85
|
+
kept verbatim, so nothing a person wrote is ever silently dropped.
|
|
86
|
+
|
|
87
|
+
## What is deliberately absent
|
|
88
|
+
|
|
89
|
+
**No required fields.** A document consisting of a name and three paragraphs is a
|
|
90
|
+
valid OpenResume.md.
|
|
91
|
+
|
|
92
|
+
**No date format.** Every attempt to impose one on resumes has failed, because people
|
|
93
|
+
write `Summer 2019` and mean it.
|
|
94
|
+
|
|
95
|
+
**No schema version.** Readers ignore what they do not recognise. A resume written
|
|
96
|
+
today has to be readable in five years by software nobody has written yet, which
|
|
97
|
+
means the format cannot have a version negotiation in it.
|
|
98
|
+
|
|
99
|
+
**No structured skills taxonomy.** Skills are the lines people wrote. Mapping them
|
|
100
|
+
onto a controlled vocabulary is a job for whatever is reading, and doing it at write
|
|
101
|
+
time destroys the information.
|
|
102
|
+
|
|
103
|
+
## Sections a parser derives, not the author
|
|
104
|
+
|
|
105
|
+
A reader may compute a structured view - name, contact pairs, sections, entries with
|
|
106
|
+
parsed date ranges - and use it for search and filtering. That view is derived, and
|
|
107
|
+
it is regenerated from the Markdown on every save.
|
|
108
|
+
|
|
109
|
+
**The Markdown is the canonical copy.** If the structured view and the document
|
|
110
|
+
disagree, the document is right. A product that stores the parse and treats the
|
|
111
|
+
Markdown as an export has not implemented this convention; it has implemented a form
|
|
112
|
+
with a Markdown skin, and the candidate no longer owns their resume.
|
|
113
|
+
|
|
114
|
+
## Converting into it
|
|
115
|
+
|
|
116
|
+
PDF, DOCX and plain text can all be converted to Markdown, and none of the
|
|
117
|
+
conversions are perfect. The rule that makes this workable: a converted document is
|
|
118
|
+
shown to the candidate to edit before it is used. A conversion nobody checks is a
|
|
119
|
+
conversion nobody should trust.
|
|
120
|
+
|
|
121
|
+
The `agenticjobs` reference implementation converts `.docx` by reading
|
|
122
|
+
`word/document.xml` directly, and `.pdf` with `pdftotext -layout`. Both are lossy in
|
|
123
|
+
ways a person spots in five seconds and a parser never will.
|
|
124
|
+
|
|
125
|
+
## Why Markdown
|
|
126
|
+
|
|
127
|
+
Because the alternative formats each fail one of the audiences.
|
|
128
|
+
|
|
129
|
+
A PDF is readable by a person and hostile to everything else; extracting a two-column
|
|
130
|
+
CV back into text loses the reading order, which is why so much hiring software
|
|
131
|
+
mangles them.
|
|
132
|
+
|
|
133
|
+
A JSON schema is readable by software and unwritable by a person; nobody drafts a
|
|
134
|
+
resume in JSON, so a tool has to generate it, and now the tool owns the resume.
|
|
135
|
+
|
|
136
|
+
Markdown is the only format that a person writes directly, a person reads directly,
|
|
137
|
+
a model writes well without being taught, `git diff` shows usefully, and a renderer
|
|
138
|
+
turns into a PDF when a PDF is genuinely what somebody wants.
|
|
139
|
+
|
|
140
|
+
## Implementations
|
|
141
|
+
|
|
142
|
+
- `agenticjobs` - the job board this convention was written for. MIT,
|
|
143
|
+
https://github.com/profullstack/agenticjobs
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
-- The board itself: who posts, what they post, and who applies.
|
|
2
|
+
--
|
|
3
|
+
-- Forward-only. Each file in this directory runs exactly once, in filename
|
|
4
|
+
-- order, inside one transaction, and is never edited afterwards.
|
|
5
|
+
|
|
6
|
+
create table if not exists organisations (
|
|
7
|
+
id uuid primary key default gen_random_uuid(),
|
|
8
|
+
slug text not null unique,
|
|
9
|
+
name text not null,
|
|
10
|
+
website text,
|
|
11
|
+
logo_url text,
|
|
12
|
+
description text,
|
|
13
|
+
created_at timestamptz not null default now()
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
create table if not exists users (
|
|
17
|
+
id uuid primary key default gen_random_uuid(),
|
|
18
|
+
email text not null,
|
|
19
|
+
name text,
|
|
20
|
+
is_admin boolean not null default false,
|
|
21
|
+
created_at timestamptz not null default now(),
|
|
22
|
+
last_seen_at timestamptz
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
-- Addresses are compared case-insensitively but stored as the person typed
|
|
26
|
+
-- them, so mail to them looks the way they expect.
|
|
27
|
+
create unique index if not exists users_email_key on users (lower(email));
|
|
28
|
+
|
|
29
|
+
create table if not exists memberships (
|
|
30
|
+
user_id uuid not null references users (id) on delete cascade,
|
|
31
|
+
org_id uuid not null references organisations (id) on delete cascade,
|
|
32
|
+
role text not null default 'owner',
|
|
33
|
+
created_at timestamptz not null default now(),
|
|
34
|
+
primary key (user_id, org_id)
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
create table if not exists jobs (
|
|
38
|
+
id uuid primary key default gen_random_uuid(),
|
|
39
|
+
slug text not null unique,
|
|
40
|
+
org_id uuid not null references organisations (id) on delete cascade,
|
|
41
|
+
title text not null,
|
|
42
|
+
description text not null default '',
|
|
43
|
+
employment_type text not null default 'full-time',
|
|
44
|
+
workplace text not null default 'remote',
|
|
45
|
+
seniority text,
|
|
46
|
+
location text,
|
|
47
|
+
remote_regions text[] not null default '{}',
|
|
48
|
+
salary_min integer,
|
|
49
|
+
salary_max integer,
|
|
50
|
+
salary_currency text not null default 'USD',
|
|
51
|
+
salary_period text not null default 'year',
|
|
52
|
+
salary_equity text,
|
|
53
|
+
tags text[] not null default '{}',
|
|
54
|
+
stack text[] not null default '{}',
|
|
55
|
+
requirements text[] not null default '{}',
|
|
56
|
+
responsibilities text[] not null default '{}',
|
|
57
|
+
agent_policy text not null default 'disclose',
|
|
58
|
+
apply_via text not null default 'board',
|
|
59
|
+
apply_url text,
|
|
60
|
+
apply_email text,
|
|
61
|
+
apply_schema jsonb,
|
|
62
|
+
status text not null default 'draft',
|
|
63
|
+
published_at timestamptz,
|
|
64
|
+
expires_at timestamptz,
|
|
65
|
+
created_at timestamptz not null default now(),
|
|
66
|
+
updated_at timestamptz not null default now(),
|
|
67
|
+
search tsvector
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
-- The search vector is maintained by a trigger rather than a generated
|
|
71
|
+
-- column: array_to_string is not immutable in every server version, and a
|
|
72
|
+
-- generated column that fails to create takes the whole migration with it.
|
|
73
|
+
create or replace function jobs_search_update() returns trigger as $$
|
|
74
|
+
begin
|
|
75
|
+
new.search :=
|
|
76
|
+
setweight(to_tsvector('english', coalesce(new.title, '')), 'A') ||
|
|
77
|
+
setweight(to_tsvector('english', coalesce(array_to_string(new.tags, ' '), '')), 'B') ||
|
|
78
|
+
setweight(to_tsvector('english', coalesce(array_to_string(new.stack, ' '), '')), 'B') ||
|
|
79
|
+
setweight(to_tsvector('english', coalesce(new.description, '')), 'C') ||
|
|
80
|
+
setweight(to_tsvector('english', coalesce(new.location, '')), 'D');
|
|
81
|
+
new.updated_at := now();
|
|
82
|
+
return new;
|
|
83
|
+
end;
|
|
84
|
+
$$ language plpgsql;
|
|
85
|
+
|
|
86
|
+
drop trigger if exists jobs_search_trigger on jobs;
|
|
87
|
+
create trigger jobs_search_trigger
|
|
88
|
+
before insert or update on jobs
|
|
89
|
+
for each row execute function jobs_search_update();
|
|
90
|
+
|
|
91
|
+
create index if not exists jobs_search_idx on jobs using gin (search);
|
|
92
|
+
create index if not exists jobs_tags_idx on jobs using gin (tags);
|
|
93
|
+
create index if not exists jobs_stack_idx on jobs using gin (stack);
|
|
94
|
+
create index if not exists jobs_org_idx on jobs (org_id);
|
|
95
|
+
-- The list page and every federated search read exactly this order.
|
|
96
|
+
create index if not exists jobs_published_idx
|
|
97
|
+
on jobs (published_at desc)
|
|
98
|
+
where status = 'published';
|
|
99
|
+
|
|
100
|
+
create table if not exists applications (
|
|
101
|
+
id uuid primary key default gen_random_uuid(),
|
|
102
|
+
job_id uuid not null references jobs (id) on delete cascade,
|
|
103
|
+
answers jsonb not null default '{}'::jsonb,
|
|
104
|
+
agent jsonb,
|
|
105
|
+
status text not null default 'new',
|
|
106
|
+
created_at timestamptz not null default now()
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
create index if not exists applications_job_idx on applications (job_id, created_at desc);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
-- Sessions, magic links, passkeys and the device flow.
|
|
2
|
+
--
|
|
3
|
+
-- Nothing here stores a credential in a form that is useful if the table
|
|
4
|
+
-- leaks: every token is kept as a SHA-256 of the value that was handed out,
|
|
5
|
+
-- so the only copy of the real token is the one on the member's machine.
|
|
6
|
+
|
|
7
|
+
create table if not exists sessions (
|
|
8
|
+
id uuid primary key default gen_random_uuid(),
|
|
9
|
+
user_id uuid not null references users (id) on delete cascade,
|
|
10
|
+
token_hash text not null unique,
|
|
11
|
+
-- What the member sees in their session list: "Firefox on Linux", "CLI on
|
|
12
|
+
-- workstation". A token they cannot recognise is a token they cannot revoke.
|
|
13
|
+
label text not null default 'session',
|
|
14
|
+
-- True for a token minted by the device flow. Such a token is never allowed
|
|
15
|
+
-- to reach the admin routes, no matter whose account it belongs to.
|
|
16
|
+
via_token boolean not null default false,
|
|
17
|
+
created_at timestamptz not null default now(),
|
|
18
|
+
last_used_at timestamptz,
|
|
19
|
+
expires_at timestamptz not null
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
create index if not exists sessions_user_idx on sessions (user_id, created_at desc);
|
|
23
|
+
create index if not exists sessions_expiry_idx on sessions (expires_at);
|
|
24
|
+
|
|
25
|
+
create table if not exists magic_links (
|
|
26
|
+
token_hash text primary key,
|
|
27
|
+
email text not null,
|
|
28
|
+
-- Where to send the member once the link is followed. Same-origin paths
|
|
29
|
+
-- only; validated before it is written, not when it is used.
|
|
30
|
+
redirect text,
|
|
31
|
+
created_at timestamptz not null default now(),
|
|
32
|
+
expires_at timestamptz not null,
|
|
33
|
+
used_at timestamptz
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
create index if not exists magic_links_email_idx on magic_links (lower(email), created_at desc);
|
|
37
|
+
|
|
38
|
+
create table if not exists passkeys (
|
|
39
|
+
id uuid primary key default gen_random_uuid(),
|
|
40
|
+
user_id uuid not null references users (id) on delete cascade,
|
|
41
|
+
credential_id text not null unique,
|
|
42
|
+
public_key text not null,
|
|
43
|
+
-- The authenticator's signature counter. A value that goes backwards means
|
|
44
|
+
-- the credential has been cloned.
|
|
45
|
+
counter bigint not null default 0,
|
|
46
|
+
transports text[] not null default '{}',
|
|
47
|
+
label text not null default 'passkey',
|
|
48
|
+
created_at timestamptz not null default now(),
|
|
49
|
+
last_used_at timestamptz
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
create index if not exists passkeys_user_idx on passkeys (user_id);
|
|
53
|
+
|
|
54
|
+
-- Held between a challenge being issued and the browser answering it. Rows
|
|
55
|
+
-- are short lived and swept on read.
|
|
56
|
+
create table if not exists webauthn_challenges (
|
|
57
|
+
challenge text primary key,
|
|
58
|
+
email text,
|
|
59
|
+
user_id uuid references users (id) on delete cascade,
|
|
60
|
+
purpose text not null,
|
|
61
|
+
created_at timestamptz not null default now(),
|
|
62
|
+
expires_at timestamptz not null
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
-- The device flow, for anything without a browser: the CLI, the TUI, MCP.
|
|
66
|
+
create table if not exists device_codes (
|
|
67
|
+
device_code_hash text primary key,
|
|
68
|
+
-- Short, typed by a human, so it is generated from an alphabet with no
|
|
69
|
+
-- characters that look like each other.
|
|
70
|
+
user_code text not null unique,
|
|
71
|
+
label text not null default 'terminal',
|
|
72
|
+
status text not null default 'pending',
|
|
73
|
+
user_id uuid references users (id) on delete cascade,
|
|
74
|
+
-- Set exactly once, when the code is approved, and cleared the first time
|
|
75
|
+
-- the waiting client collects it.
|
|
76
|
+
token text,
|
|
77
|
+
interval_seconds integer not null default 2,
|
|
78
|
+
created_at timestamptz not null default now(),
|
|
79
|
+
expires_at timestamptz not null
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
create index if not exists device_codes_expiry_idx on device_codes (expires_at);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- The directory: which instances exist, and what each of them last said about
|
|
2
|
+
-- itself.
|
|
3
|
+
--
|
|
4
|
+
-- nixamp's stream directory keeps its entries in memory with a TTL, because a
|
|
5
|
+
-- stream that stops should vanish. A job board that goes down for an afternoon
|
|
6
|
+
-- has not stopped existing, and the URLs people bookmarked still have to
|
|
7
|
+
-- resolve, so these rows are durable and `online` is derived from
|
|
8
|
+
-- `checked_at` instead.
|
|
9
|
+
|
|
10
|
+
create table if not exists instances (
|
|
11
|
+
id uuid primary key default gen_random_uuid(),
|
|
12
|
+
-- The origin, with no path, query or credentials. One row per origin.
|
|
13
|
+
url text not null unique,
|
|
14
|
+
-- The last descriptor the directory successfully fetched from the instance
|
|
15
|
+
-- itself. Never what an announcement claimed.
|
|
16
|
+
descriptor jsonb not null,
|
|
17
|
+
first_seen_at timestamptz not null default now(),
|
|
18
|
+
checked_at timestamptz not null default now(),
|
|
19
|
+
failures integer not null default 0,
|
|
20
|
+
-- An operator can refuse an instance without deleting it, so that it does
|
|
21
|
+
-- not simply re-announce itself a minute later.
|
|
22
|
+
blocked boolean not null default false,
|
|
23
|
+
blocked_reason text
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
create index if not exists instances_checked_idx on instances (checked_at desc);
|
|
27
|
+
create index if not exists instances_failures_idx on instances (failures) where blocked = false;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
-- Resumes, in Markdown.
|
|
2
|
+
--
|
|
3
|
+
-- The Markdown is the canonical copy. `parsed` is a derived cache of the
|
|
4
|
+
-- OpenResume.md structure and `search` is a derived index; both are rewritten
|
|
5
|
+
-- from `markdown` on every save and neither is ever the thing edited. A
|
|
6
|
+
-- candidate can keep several, because a resume tailored to one job is the
|
|
7
|
+
-- normal case.
|
|
8
|
+
|
|
9
|
+
create table if not exists resumes (
|
|
10
|
+
id uuid primary key default gen_random_uuid(),
|
|
11
|
+
user_id uuid not null references users (id) on delete cascade,
|
|
12
|
+
-- Unique per user, not globally: two people may both have a "backend" one.
|
|
13
|
+
slug text not null,
|
|
14
|
+
title text not null default 'Resume',
|
|
15
|
+
markdown text not null default '',
|
|
16
|
+
parsed jsonb,
|
|
17
|
+
-- private: only the owner. link: anyone with the URL. public: listed.
|
|
18
|
+
visibility text not null default 'private',
|
|
19
|
+
-- Set when the Markdown was converted from an upload, so a bad conversion
|
|
20
|
+
-- can be redone from the original instead of retyped.
|
|
21
|
+
source_name text,
|
|
22
|
+
source_mime text,
|
|
23
|
+
source_bytes bytea,
|
|
24
|
+
search tsvector,
|
|
25
|
+
created_at timestamptz not null default now(),
|
|
26
|
+
updated_at timestamptz not null default now()
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
create unique index if not exists resumes_user_slug_key on resumes (user_id, slug);
|
|
30
|
+
create index if not exists resumes_user_idx on resumes (user_id, updated_at desc);
|
|
31
|
+
|
|
32
|
+
create or replace function resumes_search_update() returns trigger as $$
|
|
33
|
+
begin
|
|
34
|
+
new.search :=
|
|
35
|
+
setweight(to_tsvector('english', coalesce(new.title, '')), 'A') ||
|
|
36
|
+
setweight(to_tsvector('english', coalesce(new.markdown, '')), 'B');
|
|
37
|
+
new.updated_at := now();
|
|
38
|
+
return new;
|
|
39
|
+
end;
|
|
40
|
+
$$ language plpgsql;
|
|
41
|
+
|
|
42
|
+
drop trigger if exists resumes_search_trigger on resumes;
|
|
43
|
+
create trigger resumes_search_trigger
|
|
44
|
+
before insert or update on resumes
|
|
45
|
+
for each row execute function resumes_search_update();
|
|
46
|
+
|
|
47
|
+
create index if not exists resumes_search_idx on resumes using gin (search);
|
|
48
|
+
|
|
49
|
+
-- An application carries a COPY of the resume, not a reference to it.
|
|
50
|
+
--
|
|
51
|
+
-- A candidate who rewrites their resume next month must not silently rewrite
|
|
52
|
+
-- what an employer already read and replied to, and a resume deleted after an
|
|
53
|
+
-- interview must not blank the application it was sent with.
|
|
54
|
+
alter table applications add column if not exists resume_markdown text;
|
|
55
|
+
alter table applications add column if not exists resume_title text;
|
|
56
|
+
-- Kept only so the candidate's own "where did I send this" list can group
|
|
57
|
+
-- applications by the resume they came from. Nulled by deleting the resume.
|
|
58
|
+
alter table applications add column if not exists resume_id uuid
|
|
59
|
+
references resumes (id) on delete set null;
|
|
60
|
+
alter table applications add column if not exists user_id uuid
|
|
61
|
+
references users (id) on delete set null;
|
|
62
|
+
|
|
63
|
+
create index if not exists applications_user_idx on applications (user_id, created_at desc);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- A human control point on the candidate's side, matching the one employers
|
|
2
|
+
-- already have.
|
|
3
|
+
--
|
|
4
|
+
-- An employer's agent can write a listing, but it lands as a draft and a
|
|
5
|
+
-- person publishes it. Until now a candidate's agent had no equivalent: it
|
|
6
|
+
-- either sent the application or it did not. This adds the same seam. An agent
|
|
7
|
+
-- prepares an application, the candidate reads it and releases it.
|
|
8
|
+
--
|
|
9
|
+
-- A draft is visible only to the candidate who owns it. It is not an
|
|
10
|
+
-- application until it is submitted, so employers never see one, counts never
|
|
11
|
+
-- include one, and the rate limit does not spend on one.
|
|
12
|
+
|
|
13
|
+
alter table applications add column if not exists submitted_at timestamptz;
|
|
14
|
+
|
|
15
|
+
-- Everything that already exists was sent, by definition.
|
|
16
|
+
update applications set submitted_at = created_at where submitted_at is null and status <> 'draft';
|
|
17
|
+
|
|
18
|
+
create index if not exists applications_draft_idx
|
|
19
|
+
on applications (user_id, created_at desc)
|
|
20
|
+
where submitted_at is null;
|