Appearance
Feed contract
Agisse's job feed reads three tables. Agisse never writes them: a separate feed provider does, on its own schedule.
The code is open source (AGPL). The job data on agisse.xyz is not: the hosted service collects and maintains it with its own provider, which is private and not part of this repository. A self-hosted copy starts with an empty feed; fill it with your own provider or the Arbeitnow importer. Without either, the rest of Agisse (pasted postings, the job board, Adzuna and ATS lookups, prep) works as usual.
This page is the interface a provider has to meet. The column lists are checked against the Drizzle schema (worker/db/schema.ts) by worker/db/feed-contract.check.ts, so they can't drift.
| Table | What it holds | Needed for |
|---|---|---|
vacancy | One open role per row, shared by every user | The feed itself |
profile_embedding | One embedding per seeker | Ranking by profile fit (optional) |
vacancy_fit | A fit score per seeker and role | A sharper fit for each seeker's best matches (optional) |
vacancy
Upsert on url. A provider that only fills the required columns gets a working feed, filtered and ranked on the seeker's degree, function, freshness and deadline.
| Column | Type | Required | Meaning |
|---|---|---|---|
url | text | yes | The posting's URL; the primary key |
source | text | yes | Where the provider found it, free text (seed marks synthetic rows) |
company | text | yes | Employer name as shown in the feed |
domain | text | no | Employer website (example.com) |
title | text | yes | Role title; HTML is stripped before display |
location | text | no | Location as the posting states it |
country | text | no | ISO 3166 code, upper case (BE, GB), or EU for remote anywhere in Europe |
city | text | no | City |
remote | boolean | no | Defaults to false |
description | text | no | Posting text; the first 2000 characters feed full-text search |
posted_at | date | no | Date the employer posted it |
deadline | date | no | Last day to apply; null when the posting gives none |
deadline_type | text | no | explicit, rolling or unknown (default). Never inferred from the post date |
first_seen | date | yes | First day the provider saw the role; drives freshness and "New this week" |
last_seen | date | yes | Last day the provider saw it listed; refresh it on every run |
closed_at | date | no | Day the employer's board stopped listing it |
level | text | no | internship or entry |
program | text | no | internship, off_cycle, summer, insight, graduate_programme or junior |
functions | text[] | no | Field keys from the list below; defaults to none |
industry | text | no | One industry key from the list below, or null |
langs_required | text[] | no | Languages the posting asks for, lower-case English names (german); a seeker must speak all of them |
embedding | halfvec(384) | no | The posting's embedding, see Embeddings |
search | tsvector | no | Generated by Postgres from title, company and description. Don't write it |
details | jsonb | no | Source-specific extras (seniority, minimum years, company stage); defaults to {} |
Allowed values
functions:ib,m&a,pe_vc,asset_mgmt,sales_trading,risk,audit,corp_finance,consulting,strategy,ops,data,product,sales,marketing,software,engineering,legal,science,healthcare,hr,design,policy,education,media,sustainability. Labels live inVACANCY_FUNCTIONS(shared/listings.ts). A role can have several.industry: the keys ofVACANCY_INDUSTRIESinshared/listings.ts.country: the keys ofVACANCY_COUNTRIES: the EU, the EEA, the UK and Switzerland, plusEU.level:internshiporentry. The feed is early-career only, so a seeker asking for "experienced" gets nothing.
When a role is open
The feed only shows a role while all three hold (open() in worker/lib/vacancies.ts):
closed_atis null,last_seenis within the last 3 days,deadlineis null or today or later.
So a provider closes a role by setting closed_at, or simply by no longer refreshing last_seen. A provider that runs less often than every 3 days empties the feed.
Keep closed rows, with closed_at set, instead of deleting them. They leave the feed, and their first_seen and closed_at show when a programme usually opens and how long it stays open. Agisse never deletes rows; delete one only for a takedown.
A reference provider
scripts/import-feed.ts loads the early-career roles from Arbeitnow's free public API (mostly Germany, France, the Netherlands and Switzerland) into vacancy, following this contract. It takes level from the title or Arbeitnow's job types, country from the location only when that names exactly one country, and functions from tags that name a field unambiguously; everything else stays empty. It refreshes only rows with source = 'arbeitnow'.
sh
npx tsx scripts/import-feed.ts --dry # download and map, write nothing
npx tsx --env-file=.env scripts/import-feed.ts # upsert into DATABASE_URLAgisse doesn't schedule it. Run it daily from cron, for example at 06:00:
text
0 6 * * * cd /path/to/agisse && npx tsx --env-file=.env scripts/import-feed.ts >> import-feed.log 2>&1Embeddings
Both embedding columns use multilingual-e5-small: 384 dimensions, stored as pgvector halfvec(384). vacancy.embedding is the posting side (e5's passage role), profile_embedding.embedding the seeker side (e5's query role). The vector extension is created by migration 0020.
The feed compares them by cosine distance and stretches the similarity from its usual 0.72–0.92 band to 0–1.
Cold start
A new seeker has no profile_embedding yet, and a role may have no embedding. Then the fit term (60% of the match) is a text match instead: the words of the seeker's degree ("MSc Supply Chain Management" becomes supply:* | chain:* | management:*) against the role's search column, as ts_rank_cd normalised to 0–1. A degree that names no field ("MSc") gives 0, so ranking falls back to function, freshness and deadline. npm run eval:feed measures this path: its personas have no embeddings.
profile_embedding
One row per seeker: their profile and résumé, embedded. Re-embed when the seeker's profile is newer than updated_at.
| Column | Type | Required | Meaning |
|---|---|---|---|
user_id | text | yes | The seeker (user.id); the primary key |
embedding | halfvec(384) | yes | The seeker-side embedding |
updated_at | timestamp | yes | The profile's updated_at when it was embedded |
A provider that writes this table reads seekers' profiles and résumés from the database. That's personal data: a self-hoster who runs one is responsible for it.
vacancy_fit
A fit score for a seeker's best cosine matches. Where a row exists, the feed uses it instead of the cosine.
| Column | Type | Required | Meaning |
|---|---|---|---|
user_id | text | yes | The seeker (user.id) |
url | text | yes | The role (vacancy.url); deleted with it |
score | double precision | yes | 0 to 1 |
profile_at | timestamp | yes | The profile's updated_at when it was scored; rescore when the profile is newer |
score is the answer on FIT_SCALE (worker/lib/vacancies.ts) divided by 3: 0 wrong field or level, ⅓ a stretch, ⅔ worth applying, 1 a strong fit. The same question scores a pasted posting's quick fit, so both read alike.
How the feed ranks
text
match = 60% fit + 15% chosen field + 15% freshness (30 days) + 10% deadline within two weeks
fit = vacancy_fit.score, else the stretched cosine, else 0No model call happens when a seeker opens the feed; everything model-made is precomputed by the provider.