The engine is not new to invent. Most of an agentic job scout already runs on the oll.am spines — the novel work is a thin slice of new pieces on top. Lead with the honest inventory: everything on the left is live today; everything on the right is the real delta.
web_search / web_fetchDon't build a job scout — re-aim the distribution scout on the spines you already own. Schedule-driven watch→score→draft→digest is solved in our own codebase; auth + payment + email are solved in Core; hybrid retrieval is solved in oll-memory; cost-capped inference is solved in oll-model. The delta is a thin service that adds sources, a cited matching engine, and a paid gate.
Three honest options. The fork is not "how good is the matching" (§6 answers that everywhere) — it's where state, sources, and billing live. The recommendation is the hybrid.
| Opt | Shape | Trade |
|---|---|---|
| A | OpenCLAW skills only | Fastest — reuses the whole proven loop verbatim. But single-tenant / personal: state lives in the agent's files, hard to bill, no per-user isolation. Great for dogfood, not a product. |
| B | Standalone oll-scout service | Flask, own Neon DB, Coolify, MCP seam — a proper multi-user paid product. But re-implements scheduling / digest that OpenCLAW already does well, and forfeits the reasoning-runtime the loop gives for free. |
| C | HYBRID recommended | An oll-scout service owns data / state / sources / billing on the spines; an agent runtime owns the reasoning steps (score / draft) via MCP; the digest + scheduling reuse the OpenCLAW loop's proven techniques. Each layer does the thing it's already best at. |
A paid product needs three things a pure-skills setup can't cleanly give: per-user isolation (saved searches, seen-state, feedback keyed off a Core JWT sub), a billing seam (Core's Stripe subscription, checked live from /me), and an auditable data store (every score row persisted with its cited rationale and cost). Those are database + service concerns — so oll-scout owns them.
But the reasoning (rank this posting, draft this digest entry) is exactly what an agent runtime + MCP tools do well, and the cadence machinery (fire-windows, double-fire prevention, smart-split digest) already runs in OpenCLAW. Hybrid keeps the service thin — it's a data / sources / billing spine with the reasoning and scheduling reused, not re-authored.
One scheduled run, ten stages. The two accent-2 bordered boxes are the only genuinely new logic; the rest re-aims the proven loop.
last_fired| Stage | What it does | Origin |
|---|---|---|
| 1 | Trigger — evaluate the fire-window; skip if already fired this window | OpenCLAW |
| 2 | Watch sources — JSON-LD career pages + one aggregator + manual paste | new |
| 3 | Normalize into canonical fields; compute content_hash | new |
| 4 | Cross-source near-dup dedup (MinHash), canonicalize to the employer ATS | new |
| 5 | Stage-1 embedding prefilter via oll-memory — drops obvious noise, cheap | oll-memory |
| 6 | Stage-2 LLM scoring via oll-model — rubric → score, cited rationale, gaps, dealbreaker flags; abstain below floor | new |
| 7 | Tiered digest assembly — ≥70 apply-grade, 50–69 watch, sorted | new |
| 8 | Deliver — Telegram + email | OpenCLAW |
| 9 | Persist seen-state | OpenCLAW |
| 10 | Feedback capture — thumbs / save / dismiss / applied | new |
Stage 2 (the expensive one) is capped at N postings/day (v0: N=25) plus a daily gateway spend cap — overflow queues to the next run. The prefilter (Stage 5) exists precisely so the frontier model only ever sees survivors: cheap-wide recall first, expensive-narrow last.
Six tables. The paid multi-user addition over the v0 draft: everything is keyed by user (Core JWT sub), and each user owns their own saved searches.
| Table | Fields |
|---|---|
profile | Versioned JSON: narrative summary; evidence map (claim → proof); title-cluster; hard gaps; dealbreakers; preferences |
source | id, type (career_page | api | manual), url, parser, schedule, active, last_run, failure_count |
posting | id, source_id, url, raw, normalized{title, company, location, pensum, language, description}, content_hash, first_seen, last_seen, status |
score | posting_id, profile_version, prefilter_similarity, llm_score (0–100), rationale[] each citing posting text + profile claim, matched_requirements[], gaps[], dealbreaker_flags[], abstained, model, cost, created_at |
digest | id, sent_at, channel, posting_ids[], render |
feedback | posting_id, user_signal (thumbs | save | dismiss | applied), created_at |
Every row above is keyed by user (Core JWT sub). Each user gets per-user saved searches — a saved search is a (title-cluster + preferences + source-set) bundle that the scheduled run ticks. This is the difference between a personal tool and a product: isolation is a column, not a fork.
The matching engine is only as safe as its input. The rule: use what employers and portals publish for machines, and never touch what they contractually forbid.
Curated CH employer career pages emit JSON-LD the employer published for Google for Jobs. The required-field contract IS the ingest schema — structured, sanctioned, free.
Fetched 1×/day, polite: a single request per page, UA identified, robots.txt respected. Adding sources beyond this set is a later milestone, not v0.
Arbeitnow (free, no-auth, DACH) as the zero-cost seed; Adzuna or Jooble free tier as the alternative — pick whichever key arrives fastest. Verify each portal's rate limits, CH coverage, and commercial terms first.
An MCP scout_add_posting(url_or_text) tool keeps the value of jobs.ch / portal-alert emails without touching them programmatically — the human pastes, the engine ingests + scores. Zero ToS surface.
hiQ WON on CFAA but LOST on breach-of-contract — permanent injunction, $500k, company dead. Indeed's ToS bans bots. The safe envelope = logged-out + machine-published JSON-LD + sanctioned APIs; GDPR still applies to any personal data. (hiQ v. LinkedIn · schema.org/JobPosting)
A keyword alert matches strings; this reasons about fit. Two-stage retrieve-then-rerank, with a scoring rubric that quotes before it judges and is licensed to abstain.
Cheap embedding recall drops obvious noise (Stage 1, oll-memory), then an expensive LLM rerank runs on just the survivors (Stage 2, oll-model). Precision where it's affordable, recall where it's cheap.
Fuse dense and keyword with RRF. Keep BM25 for exact tokens — company names, framework versions, certifications — that dense pooling averages away.
Typed JSON, contract-tested. Inputs: the normalized posting + the current profile. Output:
met / partial / gapEvery rationale item MUST quote the posting — no uncited claims. RAG discipline applied to postings: honest abstention over fabricated fit. A scout that lies about fit is worse than none. (Reductions are directional and model-dependent — validate on the real model.)
Partially superseded by §14 (2026-07-11): the personal loop is now driven by OpenCLAW through seven new job verbs on the oll-mcp thin seam; the surface below stays the plan for the paid oll-scout arm.
Six tools, all read or draft. The agent is the primary user; the tools are the seam.
| Tool | Does |
|---|---|
scout_profile_get() | Return the current versioned profile |
scout_profile_update(patch) | Apply a patch → new profile version |
scout_add_posting(url_or_text) | Ingest + score immediately; returns the cited score |
scout_add_source(url) | Validate parser; add inactive pending review |
scout_query(question) | NL query over scored postings ("public-sector above 75 this week?") |
scout_digest_now() | On-demand digest |
scout_status() | Last run, source health, spend vs cap |
Discover / rank / draft only. There is no world-changing tool in the set — so the agent cannot auto-apply by construction. Postiz teaches how to gate the one dangerous call (openWorldHint, discovery-then-act, a type enum with draft as the safe default); we take that lesson to its safe extreme and simply don't hand the agent the dangerous call. Assistierend, nie autonom.
A scout that pings on every posting trains the user to ignore it. The alerting discipline is as important as the matching.
Apply-grade (act now — top slot) vs watch (FYI). Scheduled digest, never per-posting pings — irregular sending drives more unsubscribes than frequent; cadence, not volume, is what burns trust. The user sets the frequency.
One job = one alert, with an honest first-seen date — MinHash + canonicalize to the employer ATS so reworded reposts don't re-alert.
Email = low-friction universal default + an audit trail. Telegram = opt-in high-priority breakthrough for the apply-grade tier only.
The difference between a personal tool and revenue is one seam: Core owns the money. The service just checks the plan.
A CHF 19/mo Career-Radar tier. Core owns all the Stripe code; oll-scout just checks the plan live from /me (identity in the JWT, entitlement from /me — the house rule). A RADAR_PLAN_GATE requires a non-free plan to create or tick a saved search.
subEach paying user owns their saved searches; the scheduled run ticks only active, plan-gated searches. Isolation is a column, not a fork.
Thumbs / save / dismiss are strong sparse labels that re-weight the profile; the inferred profile stays editable. Clicks are noisy abundant signal — absence ≠ dislike. The scout sharpens the longer it's used.
The free / personal scout is dogfood + the AIOps portfolio demo — it does NOT count as a stranger franc. The paid multi-user watch + alert subscription is the actual revenue product. Don't confuse the demo with the sale.
This section IS the interview demo: an agent that runs unattended, cost-capped, self-monitoring, with cited outputs. Two halves — watch it, and keep it cheap.
Request-ID JSON logs per stage + a per-run summary (sources ok/failed, postings new/scored/abstained, spend). Sentry on parse + gateway errors. A source is auto-disabled after 3 consecutive failures (alert in the digest footer). "Expected data, got zero" (a 200 OK with an empty body) is treated as an ERROR; watch volume-drop vs baseline. Health probe = DB + gateway + last-run freshness < 26h. End-to-end run tracing with token / cost.
Cheap-wide recall first (JSON-LD / API ingest + BM25 + dense), expensive-narrow last. A FrugalGPT-style cascade escalates only low-confidence cases; hard-cap the top-N to the frontier model. Batch API (async, 50% off) + prompt-cache the stable rubric prefix (0.1× reads). Tier Haiku → Sonnet → Opus.
Ordered so the earliest slice that could charge comes early. The paid gate (M3) is the milestone that turns dogfood into a product.
Profile + one JSON-LD source-watcher + Stage-2 cited-rationale scoring + a digest. Proves the loop end-to-end on a single source, personal.
The aggregator + cross-source dedup + the remaining sources + relevance calibration.
The paid gate (Core subscription + multi-user saved searches) + the feedback loop. This is the milestone that could charge a stranger franc.
Observability polish + cost controls (batch, prompt-cache, cascade tiers).
Cut from the bottom: aggregator → sources 7–10 → scout_query. NEVER cut: citations, abstention, cost cap, no-auto-apply.
Partially superseded by §14 (2026-07-11): run-location and order-of-attack are now decided — OpenCLAW drives the personal loop, oll-scout stays the paid arm, personal-first. The remaining rows below still stand.
| Decision | Options / lean |
|---|---|
| Product name | NOT "JobScout" (→ JobScout24 collision). Working name oll-scout; pick a real one Saturday. |
| Run-location | A / B / C — recommend hybrid (C) for a paid multi-user product (§2) |
| Aggregator | Arbeitnow (free, zero-auth seed) vs Adzuna vs Jooble — whichever key arrives first |
| Digest channel default | Telegram (fast to wire, better mobile) vs email (universal + audit trail) |
| Order of attack | Personal-first (dogfood, proves the loop) vs paid-multi-user-first (revenue sooner) |
Reviewed by the operating agent itself (OpenCLAW "Claw", 2026-07-11) via a gateway agent turn: verdict AMEND — all amendments incorporated below; the plan was approved contingent on the job_update state machine landing before the loop goes live.
The one-liner. The job scout is not a new build — it is three finished experiments connected. OpenCLAW (the live local agent gateway, Telegram-connected) becomes the scout's brain and hands; oll-memory (pgvector hybrid retrieval) becomes its semantic store; the oll-mcp thin seam is the only wire between them. The oll-scout service (PR #94) stays the deterministic, multi-user, PAID arm — same store, different driver.
llm_cited scorer via oll-model/Groq remains the scorer for the multi-user paid arm, where per-call cost is priced in.)@ClawBoiSamBot); alerts are a message send, not a new channel build.llm_cited scorerCollections are derived server-side from the seam's JWT sub — the client can never name another user's corpus. Status flips (new→alerted) are a re-ingest with the same document_id — idempotent, no new endpoint needed.
| Collection | Contents | Doc id | Metadata |
|---|---|---|---|
mem:user:{sub}:jobs | One doc per posting, full text | sha256(canonical url) → repost/reword = same doc, native upsert dedup | company, location, url, source, posted_date, status: new→alerted→dismissed |
mem:user:{sub}:cv | CV chunked by section (experience / skills / projects) so matching weights real project evidence over keyword lists | cv-{section} | section |
mem:user:{sub}:queries | Standing interest statements ("AIOps Zurich, senior"), themselves embedded so the query set dedupes semantically | q-{slug} | active, created |
ollam_query_list (plus ollam_job_match/ollam_job_list against the queries + cv collections as needed) — never a prompt-frozen list.ollam_job_store — the seam hashes the URL, upserts to mem:user:{sub}:jobs; an already-seen URL is a no-op and the loop skips it. This is what turns "I looked up jobs on Telegram" into a scout with memory.ollam_job_match — the seam queries the posting text against the cv and queries collections and returns vector_score per hit. Floor ~0.5 (ollwrite's measured floor on nomic) = the RECALL gate.llm_cited scorer: a per-requirement verdict, quoting the posting AND the CV chunk as evidence; it abstains on thin (<200 chars) or non-DE/EN postings. Measured below — cosine alone cannot carry precision.ollam_job_update(url, alerted, score, one-line verdict).ollam_draft, grounded by the matched CV chunks — already POC'd.ollam_* tools before anything else. MCP tools are deferred in fresh sessions; without this the agent silently degrades to ad-hoc notes — the exact failure mode the migration kills.ollam_query_store as a source: note — only when it produces a posting that stores as NEW and clears the recall floor. Good discoveries accumulate run over run; noise sources never make the list.source: notes accumulate, and per-run cost rises — discovery over consistency, because an atypical profile's best fits are non-seedable.They wrap the two existing oll-memory endpoints; corpus + acl are derived from the JWT sub exactly like ollam_memory_*.
| Verb | Does |
|---|---|
ollam_job_store(url, title, company, location, text, posted?) | Upsert doc, id = sha256(url); returns stored | already_seen |
ollam_job_match(text, top_k?) | Queries cv + queries collections; returns per-collection vector_scores + above_floor flag |
ollam_job_update(id_or_url, status, score?, verdict_note?) | The state machine the daily loop depends on. Status enum: new | watched | alerted | dismissed | applied | interviewing | rejected | expired. Under the hood it is a metadata re-ingest on the same document_id — no new oll-memory endpoint. Why it matters: URL dedup only blocks re-STORING a seen posting, not re-JUDGING it — without status write-back the agent re-judges and re-alerts the same postings every morning. |
ollam_job_list(status?, limit?) | Recent jobs by metadata filter |
ollam_cv_ingest(section, text) | Upsert cv-{section} |
ollam_query_store(slug, text, active?) | Upsert a standing interest |
ollam_query_list(active?) | Read side of standing queries — the search phase enumerates live-editable interests instead of a prompt-frozen list |
ollam_job_match is two retrieval calls + a floor — still a pure adapter, no generation.
Against the LIVE local stack (poc-oll-memory-1 on :5008 + local Ollama nomic-embed-text). Reported honestly:
search_query:/search_document: were also measured: 0.585/0.567/0.486 — no meaningful gain on short texts; not worth the complexity now.)document_id, and a reworded re-ingest produced a duplicate SIX row — the branch code supports stable-id upsert (routes.py:159), so the container is likely a stale Jul-8 image. Rebuild before relying on dedup.EMBED_PROVIDER=local), so vector_score was empty — flip to EMBED_PROVIDER=ollama + OLLAMA_EMBED_BASE_URL=http://host.docker.internal:11434 for the real loop.| # | Decision | Locked as |
|---|---|---|
| 1 | Store | oll-memory pgvector, semantic-only lookup; location/date/status are metadata filters, never word-search |
| 2 | Embeddings | Ollama nomic-embed-text, local, 768-dim (house rule: Ollama for retrieval, Groq for generation) |
| 3 | Dedup | sha256(canonical URL) as document_id (reposts change wording, not the link) — oll-memory upsert gives this for free |
| 4 | Judge | OpenCLAW's inner Claude in the personal loop (subscription = zero marginal cost), using oll-scout's cited-verdict contract; oll-scout keeps llm_cited via oll-model for the paid multi-user arm |
| 5 | Seam branch | The extension goes on feat/oll-mcp-thin (the thin seam already registered in Sam's openclaw.json); heavy PR #79 is superseded for this path |
| 6 | Seam port | HTTP port moves 5099 → 5097 (Postiz took 5099); update ~/.openclaw/openclaw.json to match |
| 7 | Sources | Open web + employer career pages; NO LinkedIn scraping; boards only via legal public surfaces (consistent with §5) |
+7 seam verbs · flip the POC memory container to ollama embeddings + rebuild (fixes the dedup flag) · re-register the seam on :5097 · one OpenCLAW cron + standing prompt → personal scout LIVE end-to-end on Telegram. Exit test: tomorrow 06:30 a real scored, cited alert arrives unprompted.
Operator conditions (Claw, 2026-07-11): (a) commit the interim memory/job-scout-zurich-aiops.md (8 postings) to the openclaw repo git BEFORE deleting it post-migration — a point-in-time snapshot in history, not a second live memory; (b) the flat CV file gets split into sections (summary / oll.am work / prior projects / stack / strengths) during migration, not assumed automatic.
Result — executed the same day it was specced:
document_id upsert verified — PLUS an additive caller-metadata-on-ingest change (feat/oll-memory@924b056) the state machine required; the running container had no metadata path at all.:5097: OpenCLAW registry fixed, 14 tools registered; the 7 job verbs shipped with 74 tests → PR #95 (awaiting Sam's merge gate; the tools run live locally from the branch regardless).49e0396e armed: daily 06:30 Europe/Zurich, isolated fresh session (forces the ToolSearch discipline). Exit test: the first unprompted cited Telegram alert, 2026-07-12 06:30.ollam_job_store (sha256(url) dedup, direct-employer-link policy with fetch verification) → the 0.64 recall floor (measured: all real postings ≥0.68, nonsense controls ≤0.60 — the old 0.5 passed even a pastry-chef control) → the inner-Claude judge with cited exhibits → Telegram alert tiers (≥85 instant / 70–84 digest / silence default) → the status state machine.score/tier/sub_scores/exhibits with posting+CV quotes and cites/abstain_reason/ruling). 31 recorded cron runs.localhost:5311, private repo): case sheets with typewritten exhibits, sub-score bars, verdict stamps, closest-miss callouts, honest abstention + a legacy fallback, RULE YOURSELF write-backs, j/k, /corpus.reason=session_expired). Fixed by re-lifting the host Keychain credentials into the container (the wiring playbook's Wall #2). LEARNING, tracked: containerized agent auth is a wasting asset — it needs either a periodic host-side credential re-sync or an alerting probe on cron status=error so a dead scout is noticed in minutes, not days. (The money-tier health watch pattern applies.)Every plan was operator-reviewed before build; the operating agent caught five real bugs on day one (the fused-score display, the cron delivery-default spam, the floor drift, its own stale source note, the chromium detection layer) and respected a privilege gate rather than routing around it; the human side applied the privileged changes with verification, which caught a silent no-op. The weekend outage confirms the next reliability rung: the scout needs a watchdog, not more features.
vector_score — it would have silently broken the 0.5 recall floor.delivery:announce, which would have pushed raw agent output to Telegram daily even on zero-posting days — patched to explicit-alerts-only.cv_read: 21 tools, 180 tests, on PR #95. New: ollam_tick_log/ollam_tick_register (rolling 8-tick register), source tri-state (pending → Sam approves via the GUI or ollam_source_rule), closest_miss first-class, an offer status, and ollam_cv_read (full-section evidence — see the defining bug below).e2ff7f0): every v2 element on REAL data with honest empty states — the tick band, THE LEDGER built from real closest_miss verdicts, lifecycle chips + advanceCase, corpus source approval, the ≥70 letterhead gate, rail filter/density, and draft VERSIONING (regenerate archives priors — the destructive-upsert bug class closed).closest_miss emission confirmed in the cron contract.The operator refused to close the AlpineAI re-judge: it could not find German fluency in the CV through any tool, so it would not rule. Told the fix was live, it refused AGAIN — it could not verify the new tool from its pre-reload session, and "accepting a plausible-sounding quote secondhand is no different from inventing one." That double refusal was correct both times, and it exposed the root cause: ollam_job_match clips evidence snippets at ~120 chars — the language line sat past the clip, so the judge kept ruling NO EVIDENCE on evidence that existed in the store. The fix is ollam_cv_read: read the full section before ruling NO EVIDENCE.
Outcome on given-content evidence: AlpineAI Senior SW Engineer 65 → 80, watched → ALERTED (Telegram sent) — a real match suppressed a full day by a rendering clip. LGT 60→63 on the same artifact; the other watched gaps were re-checked with full sections and honestly confirmed real.
Deploy oll-memory as the third spine (memory.oll.am) · merge PR #94 and point oll-scout's store at the SAME memory collections → deterministic sources + agent sources land in one corpus; oll-scout's plan-gate against oll-core (402) makes it the PAID product.
Known hardening from the M1 run: the seam JWT is 72h (expires 2026-07-14 — re-mint or wire the magic-link flow) · the seam runs as a nohup process (needs launchd for reboot survival) · recalibrate the 0.5 recall floor on live runs.
A web front door on the shared corpus — saved-searches UI, digest email via Core.
The fastest franc today is still deploying PR #94 + the live Stripe webhook (Sam console, ~3 min); this v2 layer is additive quality/coverage on the same product.
| Decision | Options / lean |
|---|---|
| Alert cadence | Instant-per-match vs 06:30 daily digest — recommend: instant for score ≥85, digest for the rest |
| Judge threshold to wake him | Recommend: alert ≥70, watch-list 50–69, mirroring oll-scout's tiers |
| Heavy PR #79 | Close as superseded? |
This spec consolidates three sources into one: the v0 draft (research/SPEC_oll-scout_v0.md — CH source list, data model, acceptance test), the techniques teardown (why each technique is good, code-verified against the live OpenCLAW scout + gitroomhq/postiz-app), and the seam architecture (the scout as a driven pattern, not a new backend). The reuse claims — OpenCLAW loop, oll-model gateway, oll-memory retrieval, oll-mcp seam, Core auth + Stripe, Telegram — are all against live services.
Honesty line holds: matching-engine reductions are directional and model-dependent — validate on the real model, not the paper. No auto-apply, ever: no submit tool is registered. Legal sourcing rests on hiQ v. LinkedIn and schema.org/JobPosting; GDPR applies. No "Swiss-hosted" / data-residency claim.
M1 — author the structured profile + wire the first JSON-LD source-watcher + the cited-rationale rubric on the proven loop. No submit tool, ever.