17 KiB
Build brief — jobbsok plugin
A local-first, read-only job search operating system for a single operator. Written for Claude Code as the implementing agent. Read the whole brief before writing any file. Build in milestone order; do not skip ahead.
Language convention: code, identifiers, file names and this brief are
English. All operator-facing content — skill output, sak.md bodies,
application drafts — is Norwegian (bokmål). The job market being served is
Norwegian.
1. Mission
Compress the job-search loop from scattered manual work into one auditable local workspace: find candidate roles, score them against a maintained candidate profile, record accept/reject decisions with reasons, learn from the pattern, draft applications, keep every case's full correspondence in one folder, prepare interviews, and never lose a thread to silence.
The operator is a single person searching for their own job. Not a recruiting tool, not multi-tenant, not a SaaS.
2. Non-negotiable constraints
These are architectural, not preferences. Violating any of them is a defect.
- Read-only against external sites. The browser layer navigates and reads. It never submits a form, never sends a message, never applies. Applications are prepared as files; the operator sends them by hand.
- No credential handling. The plugin attaches to an already-authenticated browser session over CDP. It never sees, stores, or prompts for a password.
- Human approval before any profile mutation. Learned changes to the candidate profile are proposed as diffs and applied only on explicit approval.
- Local-only data. Everything lives in the operator's workspace. Nothing is transmitted to a third party beyond the model call itself. No telemetry.
- Untrusted content stays untrusted. Every byte read from a job listing or an inbound email passes the ingestion guard before it is persisted into a file that a later skill will read as context. See §9.
- Append-only decision and event logs. Corrections are new entries, never edits.
- No autonomous scheduling. Every skill runs because the operator invoked it. Nothing polls, nothing runs in the background.
3. Target surface
Build one plugin that installs into both Claude Code and Claude Cowork — they share the plugin schema. Cowork is the intended day-to-day operator surface; Claude Code is the development and maintenance surface.
Consequences for implementation:
- Use
skills/*/SKILL.mdfor every component. Do not create acommands/directory — the legacy single-file command format is deprecated for new plugins, and Cowork presents skills and commands as one concept anyway. - Skill bodies are instructions for Claude, written imperatively ("Parse the snapshot", not "You should parse the snapshot"). They are not documentation for the operator to read.
- Frontmatter descriptions are third person and contain concrete Norwegian trigger phrases the operator would actually type.
- Keep each
SKILL.mdbody under ~3000 words. Detailed schemas, enums and worked examples go inreferences/inside the skill directory. - Use
${CLAUDE_PLUGIN_ROOT}for every intra-plugin path. Never hardcode.
4. Plugin layout
jobbsok/
├── .claude-plugin/
│ └── plugin.json
├── skills/
│ ├── kandidatprofil/
│ ├── annonse-uttrekk/
│ ├── kandidatvurdering/
│ ├── beslutning/
│ ├── sak/
│ ├── korrespondanse/
│ ├── soknad/
│ ├── intervju/
│ ├── referanser/
│ ├── forhandling/
│ ├── dagens/
│ ├── utfall/
│ ├── laering/
│ └── datahygiene/
├── scripts/
│ ├── guard_ingest.py # wraps llm-ingestion-guard
│ ├── sak_status.py # deterministic status/SLA computation
│ └── dashboard.py # renders HTML dashboard
├── templates/
├── .mcp.json
├── README.md
└── CONNECTORS.md # only if tool categories are genericised
plugin.json:
{
"name": "jobbsok",
"version": "0.1.0",
"description": "Local-first job search workspace: sourcing, scoring, case tracking, applications and interview prep.",
"author": { "name": "<operator>" },
"license": "MIT"
}
5. Workspace data model
The plugin operates on a workspace directory chosen by the operator, default
~/jobbsok-workspace. The plugin never writes outside it.
workspace/
├── profil/
│ ├── kandidat.md # single source of truth
│ └── cv/
│ ├── cv-generell-v3.pdf
│ └── varianter.jsonl # which variant went where
├── saker/
│ └── 2026-09-eksempel-as-ai-radgiver/
│ ├── sak.md
│ ├── annonse.md
│ ├── soknad-v1.md
│ ├── korrespondanse/
│ ├── kontakter.md
│ ├── intervju/
│ └── logg.jsonl
├── beslutninger.jsonl
├── referanser.md
├── arkiv/
└── .gitignore # workspace is never committed
5.1 profil/kandidat.md
YAML frontmatter carries hard filters, read mechanically by scoring. Body is prose, read by the model. The boundary is: frontmatter is absolute, body is judgement.
Frontmatter keys: oppdatert, geografi (base, maks_reisetid_min,
hjemmekontor_min_dager, vurderer_flytting), lonn (gulv_nok, onsket_nok,
kommentar), ansettelsesform (aksepterer, avviser), absolutte_nei,
senioritet (min, maks).
Body sections, in order: Kjerne (one sentence), Kompetanse (Dybde with evidence per item / Bredde / Under oppbygging), Retning (Mot / Bort fra), Signaturprosjekter (situasjon, bidrag, målbart utfall, hvilke krav det dekker), Kjente svakheter (table: svakhet, kompensasjon, tiltak), Formuleringer som virker, Profilgap.
The operator will supply an existing kandidat.md. Treat its structure as the
contract and validate against it rather than regenerating it.
5.2 saker/<sak-id>/sak.md
Case front page. Every skill reads it; several write to it. sak-id format:
YYYY-MM-<arbeidsgiver-slug>-<rolle-slug>.
Frontmatter: sak_id, arbeidsgiver, rolle, kilde, url, opprettet,
status, neste_frist, ventende_part (meg | dem | ingen),
sist_aktivitet, score, cv_variant, soknad_versjon.
Body: Hvorfor denne (from the decision reason), Status nå, Neste handling,
Åpne spørsmål, Tidslinje (human-readable, generated from logg.jsonl).
5.3 saker/<sak-id>/logg.jsonl
Append-only events. {ts, hendelse, kilde, ref, notat}. Event vocabulary is a
closed enum: opprettet, soknad_sendt, bekreftelse_mottatt,
henvendelse_mottatt, svar_sendt, intervju_avtalt, intervju_gjennomfort,
tilbud_mottatt, avslag, trukket, stille.
5.4 beslutninger.jsonl
Append-only, one JSON object per sourcing decision.
Fields: id, dato, kilde, url, tittel, arbeidsgiver, beslutning
(ja|nei), arsak (1–3 values from closed enum), notat (max 15 words),
score_da (integer, the score before the human decided), korrigerer
(nullable id).
arsak enum: lonn, geografi, arbeidsform, fagomrade, oppgavetype,
teknologi, senioritetsniva, selskapstype, arbeidsgiverrykte,
tidspunkt, konkurranse, annet.
score_da is the learning signal. High score with nei means the profile is
wrong about something; that divergence is what §5.7 mines.
5.5 profil/cv/varianter.jsonl
{variant_id, fil, sak_id, sendt_dato, tilpasninger}. The operator must be able
to answer "which CV did they actually receive" before walking into an interview.
5.6 referanser.md
Per referee: name, relation, what they can vouch for, last asked, cases used on, consent status. Skills warn before proposing the same referee a third time inside six months.
5.7 Outcome records
Outcomes are appended to the case log and mirrored into
beslutninger.jsonl as an outcome line so that learning sees entry and exit
together. Rejection stage is captured explicitly: screening, forste_intervju,
andre_intervju, referansesjekk, tilbud_avslatt. Screening rejections and
post-interview rejections are different diagnoses — the first says the profile
targets wrong, the second says the presentation does.
6. Status machine
Deterministic. Implement in scripts/sak_status.py, not in prose, so status is
reproducible and testable.
States: vurderer → soker → sendt → dialog → intervju → tilbud →
avsluttet. Terminal side-states: avslag, trukket.
Rules:
- Every state change appends to
logg.jsonland updatessak.mdfrontmatter. ventende_partis recomputed on every transition. It drives the daily view.- Silence thresholds:
sendtwith no inbound for 14 days → flag.dialogwithventende_part: demand no inbound for 7 days → flag.intervjugjennomført with no outcome after 10 days → flag. - Flags surface in
dagens. Nothing auto-escalates, auto-sends, or auto-closes. - Never invent a transition from inferred email sentiment. State changes on explicit operator confirmation or on an unambiguous structural signal.
7. Skills
Each skill directory gets SKILL.md plus references/ where the schema is
non-trivial. For each, the frontmatter description must name the trigger
phrases in Norwegian.
| Skill | Purpose | Writes |
|---|---|---|
kandidatprofil |
Read, validate, edit kandidat.md. Enforces frontmatter schema. |
profil/kandidat.md |
annonse-uttrekk |
The only skill that touches the browser. Reads an open listing, guards it, writes annonse.md snapshot. |
snapshot |
kandidatvurdering |
Score a snapshot against kandidat.md. Hard filters first, then weighted judgement. Outputs score + reasoning + concerns. |
nothing |
beslutning |
Record ja/nei with enum reason. On ja, hands off to sak. |
beslutninger.jsonl |
sak |
Create and maintain case folders. Owns the status machine. | case folder |
korrespondanse |
Ingest inbound email, guard it, match to case, file it, append event. | korrespondanse/, logg.jsonl |
soknad |
Draft application from snapshot + profile + signature projects. Versioned, never sent. | soknad-vN.md |
intervju |
Produce likely questions, three weakest points with prepared answers, questions to ask them. | intervju/ |
referanser |
Maintain referee ledger, warn on overuse. | referanser.md |
forhandling |
Prepare negotiation using floor from profile and BATNA from live pipeline. | intervju/forhandling.md |
dagens |
Daily operating view: what needs action, what has gone silent, upcoming deadlines. | nothing |
utfall |
Record outcome with rejection stage. | logs |
laering |
The profile-update loop. Explicit invocation only. | proposes diffs |
datahygiene |
Export, archive, and delete case data including third-party personal data. | archive/delete |
7.1 annonse-uttrekk specifics
Extract: title, employer, org number if present, location, deadline, employment type, stated requirements (split must-have / nice-to-have), full listing body, contact person, listing URL, capture timestamp.
The snapshot is essential because listings are deleted — usually right when interview prep needs them.
DOM selectors are the only brittle surface in the system. Isolate every selector
in references/selectors.md with a fallback strategy, so a site change is a
one-file fix and never touches scoring or drafting.
7.2 laering specifics
- Never runs automatically.
- Requires ≥10 total decisions before proposing anything.
- Reads the last 20 decisions plus all recorded outcomes.
- Proposes only on clear signal: same
arsakin ≥4 of 20, or ≥3 cases ofscore_da> 70 paired withnei. - Never tightens hard filters automatically. Salary floor, geography and absolute nos change only by direct operator edit. A run of salary rejections is as likely to mean a weak market as a wrong floor.
- If the last 20 contain fewer than 2
ja, propose widening the search rather than narrowing the profile. A loop that learns only from rejection converges on a profile that rejects everything. - Every proposal lists the decision ids it rests on.
8. MCP servers
.mcp.json declares two, both local, both least-privilege.
-
Browser — Playwright MCP attached over CDP to an existing Chrome instance started with
--remote-debugging-port=9222against the operator's normal profile. Do not launch a fresh browser; do not manage profiles. Document the launch command inREADME.md. Configure with navigation and read tools only. -
Mail — read-only IMAP against a dedicated folder the operator filters into. Credentials via environment variables, never in the repo. Matching is by employer domain plus thread key. Read scope only: the plugin must be incapable of sending.
Both are optional at install time. Every skill degrades to manual paste when its server is absent, and the plugin must be fully usable that way.
9. Ingestion security
Wire llm-ingestion-guard
(git+https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security.git)
into scripts/guard_ingest.py. Pin an exact release tag; do not vendor the
source.
Guard placement — the trust boundary is the write, because a poisoned
annonse.md is read weeks later by intervju as trusted context, and that
reader has no idea where the text came from:
untrusted text → prepare_input() → tool-less model transform → screen_output() → persist
Requirements:
- The extraction/transform step runs tool-less. A successful injection then has nothing to act with.
FAIL_SECUREhalts the write and alerts with a gate code only, never content.- Apply to both listing bodies and inbound email bodies. Email is the higher-risk channel: arbitrary senders, and the operator is primed to trust it.
- Expect false positives. Job listings are dense in the constructs the gate grades. Route non-WARN dispositions to a review queue that shows the operator the flagged region and asks for a decision — do not silently drop, and do not silently persist.
- Capability isolation: the skill that reads the browser holds no mail credential, and vice versa.
10. Visual output
Cowork renders rich output well; use it, but keep files as the source of truth.
dagensrenders a self-contained HTML dashboard: pipeline by state, actions due, silence flags, deadlines. Regenerated on each run from the case files. It is a view, never a store.- Optional xlsx export of the pipeline for operators who want it in a spreadsheet.
- Every visual must be reproducible from the workspace files alone.
11. Build order
Ship each milestone working before starting the next.
M1 — Spine. plugin.json, workspace scaffold, kandidatprofil,
kandidatvurdering against pasted listing text. No browser, no mail. Proves
scoring works before any automation exists.
M2 — Case folders. sak, status machine in sak_status.py, beslutning,
dagens as text output. The system now remembers.
M3 — Ingestion. annonse-uttrekk with browser MCP, guard_ingest.py,
snapshotting. First untrusted input enters the system, already guarded.
M4 — Communication. korrespondanse, mail MCP, silence flags wired into
dagens. "Everything in one place" becomes true here.
M5 — Output. soknad, intervju, referanser, CV variant tracking.
M6 — Learning. utfall, laering, HTML dashboard, forhandling,
datahygiene.
12. Testing
- Fixture corpus of anonymised listings and emails under
tests/fixtures/. Every skill exceptannonse-uttrekkmust be testable with zero network. sak_status.pygets real unit tests: every transition, every silence threshold, every recomputation ofventende_part.- Adversarial fixtures: a listing with an embedded instruction override, an email with a zero-click exfil carrier, a listing with hidden Unicode. Assert the guard halts and nothing is persisted.
- Golden-file tests for
kandidat.mdfrontmatter validation. - No test may require the operator's real data.
13. Non-goals
Submitting applications. Sending email. Any write to Finn.no or LinkedIn. Credential storage. Background jobs. Multi-user support. Recruiter-side features. Bulk listing harvest — the system reads listings the operator is looking at, not the database behind them.
14. Decisions to confirm with the operator before M1
Ask these; do not assume defaults.
- Workspace path, and whether it is a git repository (if yes,
.gitignoremust exclude case folders containing third-party personal data). - Which mail account and folder, or whether M4 is deferred entirely.
- Whether the scoring weights are fixed in the skill or configurable in
kandidat.mdfrontmatter. - Retention: how long archived cases are kept before
datahygieneproposes deletion. - Whether the plugin repo ships with a redacted example workspace for other users, or workspace-shaped documentation only.