Skip to content

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.

TableWhat it holdsNeeded for
vacancyOne open role per row, shared by every userThe feed itself
profile_embeddingOne embedding per seekerRanking by profile fit (optional)
vacancy_fitA fit score per seeker and roleA 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.

ColumnTypeRequiredMeaning
urltextyesThe posting's URL; the primary key
sourcetextyesWhere the provider found it, free text (seed marks synthetic rows)
companytextyesEmployer name as shown in the feed
domaintextnoEmployer website (example.com)
titletextyesRole title; HTML is stripped before display
locationtextnoLocation as the posting states it
countrytextnoISO 3166 code, upper case (BE, GB), or EU for remote anywhere in Europe
citytextnoCity
remotebooleannoDefaults to false
descriptiontextnoPosting text; the first 2000 characters feed full-text search
posted_atdatenoDate the employer posted it
deadlinedatenoLast day to apply; null when the posting gives none
deadline_typetextnoexplicit, rolling or unknown (default). Never inferred from the post date
first_seendateyesFirst day the provider saw the role; drives freshness and "New this week"
last_seendateyesLast day the provider saw it listed; refresh it on every run
closed_atdatenoDay the employer's board stopped listing it
leveltextnointernship or entry
programtextnointernship, off_cycle, summer, insight, graduate_programme or junior
functionstext[]noField keys from the list below; defaults to none
industrytextnoOne industry key from the list below, or null
langs_requiredtext[]noLanguages the posting asks for, lower-case English names (german); a seeker must speak all of them
embeddinghalfvec(384)noThe posting's embedding, see Embeddings
searchtsvectornoGenerated by Postgres from title, company and description. Don't write it
detailsjsonbnoSource-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 in VACANCY_FUNCTIONS (shared/listings.ts). A role can have several.
  • industry: the keys of VACANCY_INDUSTRIES in shared/listings.ts.
  • country: the keys of VACANCY_COUNTRIES: the EU, the EEA, the UK and Switzerland, plus EU.
  • level: internship or entry. 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):

  1. closed_at is null,
  2. last_seen is within the last 3 days,
  3. deadline is 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_URL

Agisse 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>&1

Embeddings ​

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.

ColumnTypeRequiredMeaning
user_idtextyesThe seeker (user.id); the primary key
embeddinghalfvec(384)yesThe seeker-side embedding
updated_attimestampyesThe 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.

ColumnTypeRequiredMeaning
user_idtextyesThe seeker (user.id)
urltextyesThe role (vacancy.url); deleted with it
scoredouble precisionyes0 to 1
profile_attimestampyesThe 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 0

No model call happens when a seeker opens the feed; everything model-made is precomputed by the provider.