Skip to Content
Referencekarani.toml

karani.toml

17 sections, all optional. The defaults are the behaviour karani had before the file existed, so an empty karani.toml and no karani.toml at all are the same thing — and karani init writes a populated one for you.

This file owns what to hunt and which model runs each task. Changing either is a config edit, not a code edit: that is the point of the file (ADR 0015). After a change, karani refilter re-judges every stored row for free, and newly-passing rows queue for the next qualify pass.

Resolution order, loosest first: the defaults below, then this file, then an environment variable, then a command-line flag. So QUAL_MODEL=... karani qualify is a one-off experiment and karani qualify --model ... is a one-shot, while the file is what you actually run on.

karani looks for the file in the working directory, then ~/.karani/. karani config check prints what it resolved to.

[version]

The schema version this file is written against. karani refuses a file newer than it understands rather than guessing at a field it has never seen.

KeyTypeDefault
versioninteger1

[profile]

What counts as a role you would take: which role families, which seniority bands, which skills must overlap, which titles to exclude. This is the first of the two deterministic gates and the cheapest place to change your mind — edit it, run karani refilter, and every stored row is re-judged for free.

allow_titles lifts terms out of the built-in exclusions. The defaults encode a generic software-engineering hunt; a candidate whose own background covers an excluded domain says so here (ADR 0021). Name every term that matches the title you want — allowing computational biologist does nothing while biologist is still excluded.

KeyTypeDefault
rolesstring[]["software_engineering", "ml_ai", "data", "devops_sre", "security", "research", "engineering_leadership"]
senioritystring[]["mid", "senior", "staff", "principal", "lead"]
must_have_anystring[]["python", "typescript", "javascript", "go", "golang", "rust", "java", "kotlin", "scala", "sql"]
nice_to_havestring[][]
exclude_titlesstring[][]
allow_titlesstring[][]
min_skill_overlapinteger1
[profile] roles = ["software_engineering", "ml_ai", "research"] seniority = ["senior", "staff", "principal"] must_have_any = ["python", "go", "rust", "sql"] # Lift a built-in exclusion for a domain your own background covers. allow_titles = ["computational biologist", "biologist", "bioinformatics"] min_skill_overlap = 2

[[shapes]]

A qualifying posture. Shapes are additive: a role qualifies if it fits any one of them, so this is how two different kinds of acceptable job coexist without one weakening the other. The shipped pair is global-remote-at-SF-bands and relocation-with-sponsorship (ADR 0002).

Written as repeated [[shapes]] tables in TOML.

KeyTypeDefault
namestringrequired
remoterequired, preferred, any"required"
comp_floor_usdintegerunset
destinationsstring[][]
requiresstring[][]
# Shape one: they pay San Francisco bands wherever you live. [[shapes]] name = "global-remote-sf-bands" remote = "required" comp_floor_usd = 160000 # Shape two: they sponsor a visa, and local top-of-market is fine there. [[shapes]] name = "relocation-eu-japan" remote = "any" destinations = ["DE", "NL", "IE", "JP"] requires = ["visa_sponsorship"]

[positioning]

The paragraph templated into every prompt. This is what de-personalises karani for a second user: nothing about the candidate is hard-coded in the prompts, it is rendered from here.

KeyTypeDefault
based_instring"Nairobi, Kenya"
candidatestring"a senior/staff engineer"
narrativestring"targeting fully-remote roles at globally-distributed companies that pay at San Francisco bands (~$160k+ base, ideally $220k+ TC) regardless of candidate location, or roles that sponsor a visa and relocation (EU and Japan preferred, local top-of-market comp acceptable there)"
excluded_domainsstring"computational-biology / bioinformatics roles"

[[targets]]

Per-company ATS boards to poll, as repeated [[targets]] tables. Leave it empty to keep the built-in curated list. A slug is the company’s own identifier on that ATS: the acme in boards.greenhouse.io/acme.

KeyTypeDefault
sourcegreenhouse, lever, ashby, workablerequired
slugstringrequired
display_namestringempty
notesstringempty
[[targets]] source = "greenhouse" slug = "northwind-robotics" display_name = "Northwind Robotics" [[targets]] source = "lever" slug = "helixdata" display_name = "Helix Data"

[llm]

Which model runs each task, independently routable. This is the block that decides what karani costs: point [llm.qualify] at a local model and bulk qualification is free, while [llm.draft] keeps a strong hosted model for the thing that actually gets the interview.

Every task falls back to [llm.default] for any knob it does not set. provider is any name in the registry — openrouter, openai, anthropic, local, or one your own code registered (ADR 0017). api_key_env is the name of an environment variable, so pointing the openai provider at Groq or Together never puts a key in this file.

Sub-tables, each the same shape: [llm.default], [llm.qualify], [llm.draft], [llm.humanize], [llm.tailor], [llm.prep], [llm.followup], [llm.agent], [llm.email].

Any knob a task leaves unset falls back to the one in [llm.default], so a single provider there covers every task you have not routed by hand.

KeyTypeDefault
providerstringunset
modelstringunset
effortstringunset
max_tokensintegerunset
base_urlstringunset
api_key_envstringunset
timeoutintegerunset

[memory]

What karani retains between passes. mode is the switch: off, basic (a deterministic ledger in Postgres, the default) or mem0 (a semantic index over that same ledger). The ledger is the system of record and the index is derived and disposable, so any mem0 failure degrades to basic rather than losing anything (ADR 0009).

KeyTypeDefault
modeoff, basic, mem0unset
pg_urlstringunset
ollama_urlstringunset
llm_modelstringunset
embedder_modelstringunset
embed_dimsintegerunset
collectionstringunset
user_idstringunset

[slack]

The two-way Slack surface. Tokens live in .env; this says which conversation and, optionally, who may drive it. An empty allowed_users allows everyone, which is correct for a single-user DM and wrong for a shared channel.

KeyTypeDefault
channelstringunset
allowed_usersstring[][]

[notion]

The Notion mirror. One-way: Postgres stays the source of truth (ADR 0011). karani notion init prints the database id to put here.

KeyTypeDefault
database_idstringunset

[artifacts]

Per-job object storage for the rendered documents, and the presigned links that let you open one on a phone (ADR 0014).

KeyTypeDefault
endpointstringunset
bucketstringunset
securebooleanunset
presign_daysintegerunset

[autopilot]

The continuous hunt’s spending limits. Triple-bounded on purpose: a fit floor, a per-run cap, and one shared daily budget across all 24 hourly runs. Setting the per-run cap to 0 disables drafting without disabling the pass.

A fit score is only comparable within the model and prompt version that produced it, so a floor tuned against one scorer will match almost nothing under another — measured over 598 rows, one model’s mean was 38 and another’s 47 (ADR 0022). Change the qualify model and re-check this number.

KeyTypeDefault
min_fitintegerunset
max_draftsintegerunset
max_drafts_per_dayintegerunset
verifybooleanfalse
[autopilot] min_fit = 85 max_drafts = 3 max_drafts_per_day = 5 verify = false

[hunt]

How much one pass may take on. qualify_limit is the knob that has to fit the hour: on a local model a row takes 75-170 seconds, so 15 rows is about half an hour and a pass that cannot finish silences every pass after it (ADR 0025).

KeyTypeDefault
qualify_limitinteger50
min_comp_usdintegerunset
target_comp_usdintegerunset

[watcher]

How much mail the response watcher reads. Credentials are env-only (GMAIL_*); this block only says how far back to look. The OAuth scope is gmail.readonly and nothing else, so the watcher cannot send, delete or mark anything read.

KeyTypeDefault
lookback_daysinteger14
max_messagesinteger50
querystringempty
[watcher] lookback_days = 14 max_messages = 50 query = "-category:promotions"

[budget]

karani’s slice of the LLM account — not the whole balance. Past the cap, billed providers are swapped for the local model and the pass announces the change once. Billed work degrades; it never halts and never overspends (ADR 0019).

KeyTypeDefault
monthly_usdnumber15.0
warn_atnumber0.8
[budget] monthly_usd = 15.0 warn_at = 0.8

Declared legal facts, and the most consequential block in the file. Work authorization, visa sponsorship, relocation and start date are derived from these by karani/legal.py on every surface — the pack, the answer sheet, the console. A model may receive the resolved line; it never writes one, because these are attestations you sign and the same question has opposite correct answers depending on the role’s shape (CLAUDE.md 4.12).

An unknown geography resolves to needs review, never to an assumed yes.

KeyTypeDefault
citizenshipsstring[][]
based_in_countrystringempty
work_authorized_instring[][]
remote_engagementstring[]["employer_of_record", "contractor"]
relocation_open_tostring[][]
notice_period_daysinteger0
[legal] citizenships = ["KE"] based_in_country = "KE" work_authorized_in = ["KE"] # How a remote employer can engage you without a visa. remote_engagement = ["employer_of_record", "contractor"] relocation_open_to = ["DE", "NL", "IE", "JP"] notice_period_days = 30

[profile_sources]

Where the profile core reads your own records from. The LinkedIn path is the official data export, never a scraper.

KeyTypeDefault
linkedin_exportstringempty
portfolio_urlstringempty

[coach]

Interview prep on a state change. It is billed and unattended, so it has a switch within reach: this field, and KARANI_AUTO_PREP=off at the shell, which overrides it (ADR 0024).

KeyTypeDefault
auto_prepbooleantrue
Last updated on