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.
| Key | Type | Default |
|---|---|---|
version | integer | 1 |
[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.
| Key | Type | Default |
|---|---|---|
roles | string[] | ["software_engineering", "ml_ai", "data", "devops_sre", "security", "research", "engineering_leadership"] |
seniority | string[] | ["mid", "senior", "staff", "principal", "lead"] |
must_have_any | string[] | ["python", "typescript", "javascript", "go", "golang", "rust", "java", "kotlin", "scala", "sql"] |
nice_to_have | string[] | [] |
exclude_titles | string[] | [] |
allow_titles | string[] | [] |
min_skill_overlap | integer | 1 |
[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.
| Key | Type | Default |
|---|---|---|
name | string | required |
remote | required, preferred, any | "required" |
comp_floor_usd | integer | unset |
destinations | string[] | [] |
requires | string[] | [] |
# 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.
| Key | Type | Default |
|---|---|---|
based_in | string | "Nairobi, Kenya" |
candidate | string | "a senior/staff engineer" |
narrative | string | "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_domains | string | "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.
| Key | Type | Default |
|---|---|---|
source | greenhouse, lever, ashby, workable | required |
slug | string | required |
display_name | string | empty |
notes | string | empty |
[[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.
| Key | Type | Default |
|---|---|---|
provider | string | unset |
model | string | unset |
effort | string | unset |
max_tokens | integer | unset |
base_url | string | unset |
api_key_env | string | unset |
timeout | integer | unset |
[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).
| Key | Type | Default |
|---|---|---|
mode | off, basic, mem0 | unset |
pg_url | string | unset |
ollama_url | string | unset |
llm_model | string | unset |
embedder_model | string | unset |
embed_dims | integer | unset |
collection | string | unset |
user_id | string | unset |
[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.
| Key | Type | Default |
|---|---|---|
channel | string | unset |
allowed_users | string[] | [] |
[notion]
The Notion mirror. One-way: Postgres stays the source of truth (ADR 0011). karani notion init prints the database id to put here.
| Key | Type | Default |
|---|---|---|
database_id | string | unset |
[artifacts]
Per-job object storage for the rendered documents, and the presigned links that let you open one on a phone (ADR 0014).
| Key | Type | Default |
|---|---|---|
endpoint | string | unset |
bucket | string | unset |
secure | boolean | unset |
presign_days | integer | unset |
[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.
| Key | Type | Default |
|---|---|---|
min_fit | integer | unset |
max_drafts | integer | unset |
max_drafts_per_day | integer | unset |
verify | boolean | false |
[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).
| Key | Type | Default |
|---|---|---|
qualify_limit | integer | 50 |
min_comp_usd | integer | unset |
target_comp_usd | integer | unset |
[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.
| Key | Type | Default |
|---|---|---|
lookback_days | integer | 14 |
max_messages | integer | 50 |
query | string | empty |
[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).
| Key | Type | Default |
|---|---|---|
monthly_usd | number | 15.0 |
warn_at | number | 0.8 |
[budget]
monthly_usd = 15.0
warn_at = 0.8[legal]
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.
| Key | Type | Default |
|---|---|---|
citizenships | string[] | [] |
based_in_country | string | empty |
work_authorized_in | string[] | [] |
remote_engagement | string[] | ["employer_of_record", "contractor"] |
relocation_open_to | string[] | [] |
notice_period_days | integer | 0 |
[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.
| Key | Type | Default |
|---|---|---|
linkedin_export | string | empty |
portfolio_url | string | empty |
[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).
| Key | Type | Default |
|---|---|---|
auto_prep | boolean | true |