jobbsok/docs/build-brief.md
Kjell Tore Guttormsen 05a7aa1d6f feat: initial commit — plugin manifest and build brief
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:27:23 +02:00

17 KiB
Raw Permalink Blame History

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.

  1. 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.
  2. No credential handling. The plugin attaches to an already-authenticated browser session over CDP. It never sees, stores, or prompts for a password.
  3. Human approval before any profile mutation. Learned changes to the candidate profile are proposed as diffs and applied only on explicit approval.
  4. Local-only data. Everything lives in the operator's workspace. Nothing is transmitted to a third party beyond the model call itself. No telemetry.
  5. 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.
  6. Append-only decision and event logs. Corrections are new entries, never edits.
  7. 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.md for every component. Do not create a commands/ 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.md body under ~3000 words. Detailed schemas, enums and worked examples go in references/ 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.jsonl and updates sak.md frontmatter.
  • ventende_part is recomputed on every transition. It drives the daily view.
  • Silence thresholds: sendt with no inbound for 14 days → flag. dialog with ventende_part: dem and no inbound for 7 days → flag. intervju gjennomfø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 arsak in ≥4 of 20, or ≥3 cases of score_da > 70 paired with nei.
  • 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.

  1. Browser — Playwright MCP attached over CDP to an existing Chrome instance started with --remote-debugging-port=9222 against the operator's normal profile. Do not launch a fresh browser; do not manage profiles. Document the launch command in README.md. Configure with navigation and read tools only.

  2. 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_SECURE halts 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.

  • dagens renders 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 except annonse-uttrekk must be testable with zero network.
  • sak_status.py gets real unit tests: every transition, every silence threshold, every recomputation of ventende_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.md frontmatter 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.

  1. Workspace path, and whether it is a git repository (if yes, .gitignore must exclude case folders containing third-party personal data).
  2. Which mail account and folder, or whether M4 is deferred entirely.
  3. Whether the scoring weights are fixed in the skill or configurable in kandidat.md frontmatter.
  4. Retention: how long archived cases are kept before datahygiene proposes deletion.
  5. Whether the plugin repo ships with a redacted example workspace for other users, or workspace-shaped documentation only.