- Nexa
- What makes it different from a chat window
- Install
- Usage
- The corpus is the source of truth
- The checkers are tested, then attacked
- Patterns are tested against their own samples
- Cross-checking measures false positives
- Then the checkers get attacked
- And then pointed at code nobody wrote for them
- What it misses
- What the embedder drops
- The missed list is the worklist
- An outside opinion, which is the one thing the corpus cannot give itself
- Every version claim is checked against two kinds of evidence
- Is every rule findable?
- And what does
nexa checkactually report?
- Environment
- Requirements
- Documentation
- License
- What you get
ββ ββ ββββββ ββ ββ βββββ
βββ ββ ββ ββ ββ ββ ββ
ββββ ββ βββββ βββ ββ ββ
ββ ββββ βββββ βββ βββββββ
ββ βββ ββ ββ ββ ββ ββ
ββ ββ ββββββ ββ ββ ββ ββ
ββββββββββββββββββββββββββββββ
Nexa
Offline validation intelligence for Odoo and TypeScript/JavaScript.
A local AI assistant that answers from a schema-validated rule corpus, checks its own output against that corpus, and escalates to a larger model only when a check fails.
Everything runs on your machine. No API keys, no network calls, no client code leaving the laptop.
No model weights live here. Nexa is the corpus, the checking pipeline, and the CLI β a few hundred kilobytes. It orchestrates open models you pull from Ollama's registry (Qwen2.5-Coder, Qwen3-Coder, nomic-embed-text), which keep their own upstream licenses. See INSTALL.md.
What makes it different from a chat window
A general model will confidently tell you to guard a value with @api.onchange. Nexa
will not, because "onchange is not validation" is a rule in a corpus, not a hope
about the prompt β and because Nexa checks its own answer against that rule before
showing it to you.
Every answer goes through nine layers. Each one reports a verdict, what it checked, and what it could not check and why:
| Layer | Catches |
|---|---|
| Syntactic | Code that does not parse or compile |
| Structural | An answer missing the sections the format requires, or echoing the template |
| Semantic | An @api.constrains that omits a field its body reads β an AST pass, not a regex |
| Knowledge | A rule id that does not exist, or one recited rather than read |
| Domain | A rule cited correctly and then broken anyway |
| Context | An answer that is right in general and wrong for this module's Odoo series |
| Empirical | An import naming a module that is not installed here |
| Adversarial | A verdict that only holds for one particular formatting |
| Provenance | A verdict nobody could reconstruct later |
Most tools stop at the knowledge layer -- do the cited rules exist? Nexa does not, because a model will cite a rule correctly and break it in the same answer β every citation checking out while the code does the opposite of what it just quoted.
Citation existence is not rule compliance.
Compliance checking alone is not the end of it either. A regex over generated code
cannot see that @api.depends('line_ids') is incomplete when the body reads
line_ids.price. That is a set comparison, so Nexa parses the code and does it
properly.
It tells you what it did not check
Every layer reports its own coverage. A layer that cannot run says so, and the verdict carries that forward as INSUFFICIENT EVIDENCE rather than quietly counting as a pass β because nothing found and nothing to find are different claims.
Confidence comes back as four independent readings β structural, semantic, contextual, empirical β never averaged into a single score. One number reads high whenever the cheap checks ran, which is exactly the false reassurance this design removes.
REJECT (INSUFFICIENT EVIDENCE: the domain layer could not run)
confidence contextual partial empirical none semantic partial structural checked
Knowing the difference between verified and not yet falsified is what separates a validator you can build on from a green tick you cannot.
Install
See INSTALL.md. Short version: clone this repo, install Ollama, pull
three models (~22 GB, from Ollama's registry β not from here), run nexa build.
Usage
nexa banner and command list
nexa help <command> detail for one command
Ask
nexa ask odoo "constrain invoice date to the fiscal year of its period"
nexa ask typescript "validate an Odoo res.partner JSON-RPC payload"
nexa ask odoo --deep "should this be a stored computed field or a related field?"
--deep uses the 30B instead of the 7B: roughly 11 tok/s instead of 37, noticeably
better at weighing trade-offs.
Run the full pipeline
nexa run odoo "cap a sale order line quantity at available stock"
Retrieves rules, drafts on the 7B, runs all nine layers, and escalates to the 30B once -- but only for findings a larger model could plausibly repair. A missing section label is a real finding that no bigger model fixes better, and spending 11 tok/s to discover that would make the rescue rate meaningless.
Escalation is bounded by declared workflow topology, so a run can never burn a second pass on the slow model.
Review a file
nexa check addons/my_module/models/sale_order.py
The checkers run over your file first, before any model is involved. Patterns and AST passes, each finding carrying the line it sits on. These do not depend on what a 7B says β or on whether it says anything useful at all:
6 checker finding(s) in the file
critical line 75 odoo.stored-compute-needs-complete-depends
_compute_labour_cost() reads 'job_id' but @api.depends does not declare it
-- the stored value goes stale when 'job_id' changes
critical line 181 odoo.constrains-must-list-every-field-read
_check_no_overlap() reads 'end_at' but @api.constrains declares only
engineer_id -- writing 'end_at' alone skips the check entirely
Then the model is asked to explain and fix those, and to look for what no pattern can decide. It is shown the findings, so it works from them instead of re-deriving them.
A file too big for the context window loses whole symbols, and they are named. The excerpt is chosen by symbol β methods, classes, functions β and the ones a checker fired on go in first, so the part of the file somebody needs an answer about is the part that survives:
! file is 7929 characters; 11 of its 20 symbols were reviewed
(not shown: FsmVisit.action_arrive, FsmVisit._timesheet_vals, ...)
The verdict carries it too, so a review of part of a file cannot print a bare PASS:
WARN (INSUFFICIENT EVIDENCE: 9 of 20 symbols were not reviewed)
--no-model stops after the checkers. 0.26 seconds, no Ollama, and the same
output every run β because what the generative half adds on top of the checkers is
small and does not hold still: 1 citation across 25 reviews on one run, 3 on
the next, and not the same rules either time. On those same two runs the deterministic
half produced the identical 23 findings both times.
That is the argument. A person reading a file wants the part no pattern can decide, and it is worth the wait. A CI gate wants the part that is the same every time, and three of five files gave different verdicts between those two runs. Those had been the same command.
It prints no verdict, because nothing generated an answer to have a verdict about. A clean run says no pattern fired in those words, and exits 1 if one did.
For a .py file the extension only really says not TypeScript, so the file
decides which corpus applies: an odoo import or a __manifest__.py above it means the Odoo rules,
anything else means the plain-Python ones.
checking upload_handler.py as python
Rules retrieved:
0.698 [critical] python.assert-is-not-validation
0.685 [critical] python.eval-and-pickle-execute-their-input
0.674 [high] python.path-join-does-not-contain-a-path
0.643 [critical] python.subprocess-shell-true-is-injection
--stack overrides the detection in either direction. Unlike nexa ask, this one has surroundings to read: the nearest
__manifest__.py gives the Odoo series, the file gives its model names and existing
imports, the nearest tsconfig.json says whether strict is on. Those facts go into
the question and into the context layer.
Where they say the corpus was verified somewhere else, that is reported as a gap, not a finding β it is true of the corpus whatever the answer says, and a verdict that fires on every run in every project off the newest series stops being read:
Not checked
context version-specific behaviour
this module targets Odoo 17.0 and the corpus was checked against
18.0, so anything version-specific in these rules is unverified here
A rule that was actually retrieved and genuinely does not apply to this series is a different claim, and stays a finding with the rule id attached.
Ask about your own code
nexa index C:/work/our-standards
nexa advise "how do we handle schema migrations here?"
index embeds a folder of your own source and documentation into a separate table
from the rule index. advise reads it back.
This is the one command not grounded in the rule corpus, so nothing it says is
checked against a rule id or a violation pattern β it answers from whatever you
indexed, and says so when the material does not cover the question. Use ask, run
and check for anything you want verified.
Show the record behind a verdict
nexa evidence
nexa evidence ask-34cf01432145
Every run writes one bundle: the corpus hash, the rules retrieved with their scores and which arrived by pin, every layer's verdict and findings, the checker versions, the model and its options, and the timings.
A verdict nobody can reconstruct is a claim, not a result. Question and answer text
are omitted unless LOCALAI_TELEMETRY_CONTENT=1, for the same reason telemetry omits
them β this runs on a machine used for client work. NEXA_EVIDENCE=0 turns bundles
off.
Explore the corpus
nexa rules
nexa rules onchange
nexa rules --full odoo.constrains-ignores-dotted-names
β marks a rule with an automated check. β marks one you have to read the answer
for yourself. PIN marks a rule injected into every answer regardless of retrieval.
Keep it healthy
nexa doctor
Checks Ollama, the models, GPU residency, and the traps that silently cost ~3x throughput on a 6 GB card. Run this first whenever something feels slow.
nexa models
nexa report
Change the rules
Edit corpus/build_corpus.py, then:
nexa build
That regenerates the rule JSON, validates it, measures pattern false positives,
attacks the checkers, re-embeds the retrieval index, and rewrites the Modelfiles. It prints the
ollama create commands to register the updated models.
The corpus is the source of truth
corpus/rules/*.json holds the rules as structured data β a citable id, two
graded axes, the failure it prevents, a wrong/correct code pair, and where possible
an upstream documentation URL and a regex that detects the violation in generated
code.
The axes are separate because they answer different questions. severity is how
bad it is if this happens; likelihood is how likely a finding is the bug, graded
from what nexa recall --aim measured against real fix commits rather than from
judgement. A rule can be severe and rarely the point β a Python uniqueness check
really does race, and across 160 fixes not one of them was about that β and one
field could not say so.
Everything else is derived and must never be hand-edited:
corpus/rules/*.json --+--> modelfiles/gen-*.Modelfile (system prompts)
+--> bin/index.sqlite (retrieval)
+--> every answer (grounding + checking)
Same principle as z.infer<typeof Schema>: one definition, derived artifacts, no drift.
Current coverage
| Rules | 44 β 19 Odoo, 10 TypeScript, 8 Python, 7 JavaScript |
| Critical | 17 |
| Machine-checkable | 41 (93%) β 32 by pattern, 9 by AST |
| Traceable to upstream docs | 41 (93%), each audited for topic fit |
| Pattern false positives | 0 cross-hits across the corpus |
| Recall under meaning-preserving rewrite | 101/101 |
| Satisfied patterns bypassable by a comment | 0 of 14 |
| Retrievable by their own probe | 44 of 44, at top 3 |
| Hit rate on 34,550 lines of reviewed OCA code | 3.9 per 1000 lines |
| Hit rate on 229,600 lines of OpenSPP (Odoo 19) | 1.4 per 1000 lines, +26 another tool had already flagged, +71 advisory |
| Recall against real Odoo bugfixes | 1 of 56 (OCA), 6 of 160 (OpenSPP). See below. |
| Verdict stability, 12 questions x 5 runs | 12/12 same verdict every run |
| Escalation rescued a bad draft | 11 of 21, 95% CI 32β72% |
nexa check, 5 files x 5 runs |
23 findings reported, in 5 of 5 files β every one the corpus can make |
| The same 23, on a second run of the identical command | 23 β the deterministic half does not move |
| Rules the model added beyond the checkers | 1 on one run, 3 on the next, out of 25 reviews |
nexa check verdict stability |
4 of 5 files on one run, 3 of 5 on the next |
nexa check --no-model |
0.26s, identical output every run |
41 of 44 rules carry an automated check. The remaining three are decidable only with
context a snippet does not contain, so they return no verdict β never a false pass
β and nexa coverage names each one and why.
The checkers are tested, then attacked
Patterns are tested against their own samples
Every rule carries a wrong and a correct sample, so nexa validate tests each
pattern against both. Three faults are hard errors:
- a pattern that does not match its own
wrongsample β it catches nothing - a pattern that fires on its own
correctsample β it cries wolf on good code - a
satisfied_patternthat clears its ownwrongsample β inert by construction
AST checkers owe the same contract and are self-tested the same way.
Cross-checking measures false positives
nexa crosscheck fires every checker at every other rule's correct sample. That is
the false-positive rate, and it decides whether a checker gets trusted or ignored.
Then the checkers get attacked
nexa adversarial
validate and crosscheck both compare a checker against code written alongside it,
by the same hand, in the same sitting. Neither asks what it takes to get bad code
past it. Three probes do:
| Probe | Question |
|---|---|
evasion |
Reformat the wrong sample without changing its meaning. Does the checker still see it? |
counterexample |
Can a satisfied_pattern be cleared by a comment quoting the fix instead of applying it? |
contradict |
When one rule fires on another's correct sample, is that breadth or do the two rules disagree? |
This runs as part of nexa build. A checker that has never been attacked is a checker
nobody has tested.
The probes hold the corpus to a measured standard, and it currently meets all three: 64/64 rewrites still caught, 0 of 12 satisfied patterns bypassable by a comment, 0 contradictions between rules. Those numbers are regenerated on every build, so a checker cannot quietly weaken.
And then pointed at code nobody wrote for them
validate, crosscheck and adversarial all compare a checker against the 32
samples in this corpus β written by the same hand that wrote the checkers. Passing
proves self-consistency and nothing else.
nexa fieldtest ../server-tools ../account-financial-tools
fires every checker at a tree of somebody else's source and reports the hit rate per thousand lines. On code that already works, every hit is a question about the checker, not about the code.
Against OCA/server-tools and OCA/account-financial-tools β 606 files, 34,550 lines of reviewed, merged Odoo:
| Hits | Per 1000 lines | |
|---|---|---|
| Before | 397 | 11.3 |
| After narrowing five checkers | 135 | 3.9 |
What the first run found:
onchange-is-not-validationfired on all 16@api.onchangedeclarations that had no constraint elsewhere in the file. Not one was validating anything β they set a field from a provider, fill a default, narrow a domain. It now requires the onchange to actually try to enforce something. 16 β 0.stored-compute-needs-complete-dependsdemanded@api.dependsentries the ORM would refuse: paths ending in.id, and fields the method computes itself. 121 β 95. Its true positives stand βauditlog's storeddisplay_namereadsuser_id.nameand declares onlyuser_id, so the name goes stale on a rename.prefer-sql-constraintread a guard as the whole rule.if record.storage == "url" and not record.url:β the first half is anast.Compareand the second is not, so it concluded the function "does nothing else". 8 β 2, and both survivors are true positives.ondelete-is-a-decisionis the one left loud on purpose. Odoo defaults a requiredMany2onetoondelete='restrict', which is what the rule recommends, so 56 of its 198 hits were fields already doing the right thing and are now exempt. The remaining 142 are optionalMany2ones genuinely defaulting toset nullβ true positives at 4.1 per 1000 lines, and the rate to expect on a first run against an existing codebase.
What it misses
Every number above is a precision number. A corpus containing nothing at all would score perfectly on all of them, so none of them is evidence that the corpus is worth having.
nexa recall ../server-tools ../account-financial-tools
reads a repository's git history, finds the commits where somebody fixed something, and runs the checkers over each file as it stood before the fix and again after. A finding that was there before, is gone after, and sat on a line the fix actually changed is the corpus noticing the bug that was really fixed.
Across 385 fix commits in those two repositories, 56 of which touched a construct the corpus covers:
| Caught | 1 |
| Missed | 55 |
| Rules that caught anything | 1 of 14 |
That last condition, on a line the fix changed, is the entire measurement.
Without it, any refactor that moves code counts as a catch. The first version of
this tool did not have it and reported 12. Reading the commit subjects killed
the result: "fix image url in locales with thousand separator", "poor
performance updating assets massively", "fix JS crash in analytic
distribution". Not one was an ondelete bug. The checker had simply gone quiet
because a dereference moved.
What this means, stated plainly. Nexa is precise and narrow. It is right about what it describes, and it describes very little of what actually goes wrong. The 55 misses include bugs squarely inside its own stated territory:
- "remove sudo() when importing record" β
sudo-bypasses-record-rulesexists - "prevent removal of x2many values from inaccessible companies" β a
multi-company leak, which
check-company-on-relationsis about - "log computed fields stored in db as expected" β a stored compute
What this does not mean. The denominator is generous. Many of those 56 commits touched a covered construct while fixing something else entirely, so 2% understates recall against validation bugs specifically. Two repositories in one ecosystem is also a narrow sample. The number is a floor, and it is the first floor this project has had.
The missed list is the more useful output. Every entry is a bug somebody actually fixed that the corpus had nothing to say about, which makes it a candidate rule with a real incident behind it rather than another one written from an opinion.
What the embedder drops
Retrieval embeds the question and sorts the rule vectors against it. For a long file
that question is large, and nomic-embed-text truncates what will not fit without
saying so. Appending a marker and watching the vector fail to move puts the cut at
~7,400 characters β the last 6% of the longest subject's question, not the back
half anybody would have guessed.
nexa eval --compare-retrieval
scores a compact description of a file against the whole question, using the rules the checkers proved apply as ground truth. The compact query wins 15β14 out of 19: one rule, on one file, which is a coin toss with a table around it. So it is not adopted. The function stays as the thing that comparison compares, and the committed behaviour waits for a number that says something.
What is adopted needed no measuring: a rule the checkers already fired on at line 75 goes into the grounding without asking similarity to guess at it.
The missed list is the worklist
nexa recall ../OpenSPP2-19.0 --harvest
clusters the fixes the corpus did not flag on the line of the fix by the construct each diff touched, so the reading order is the writing order. Against OpenSPP 2 (Odoo 19) β a second ecosystem on a newer series than the OCA repositories:
| Fix commits examined | 406 |
| β¦touching a construct the corpus covers | 160 |
| Caught | 6 |
| Recall against in-scope fixes | 4% |
Two repositories, two ecosystems, 2β4% both times. The floor is a floor β and the clustering says where to dig:
missed caught spoke silent
.sudo( 70 6 47 23
api.depends 23 0 16 7
api.constrains 16 0 12 4
ondelete 13 0 10 3
spoke is the column that changed the reading. A miss is not automatically a
blind spot: a checker firing somewhere else in the same file means the rule exists
and is aimed wrong, which wants a sharper rule rather than a new one. Ninety-two of
the 154 misses are that. The first version of this table reported 21x api.depends (nothing caught here), which reads as a missing rule β and the checker was in fact
firing on sixteen of those files while the fixes went elsewhere.
Reading the diffs behind the seven that really were silent produced one rule,
odoo.depends-must-not-name-unread-fields. Two of the remaining rules in this corpus
came from the crosswalk below.
An outside opinion, which is the one thing the corpus cannot give itself
nexa crosswalk ../OpenSPP2-19.0/.semgrep/odoo-security.yml --checkout ../OpenSPP2-19.0
Every other number here tests whether what we wrote behaves. None of them can ask whether it is the right set of rules to have written, because a rule nobody thought of is invisible to every check that reads the rules. OpenSPP runs semgrep against 44 hand-written Odoo rules of its own β somebody else's judgement, on the same platform, formed without reading ours.
Semgrep is never run: each of their patterns is turned into the smallest snippet it would match and our checkers run over that, so a pairing rests on a checker firing rather than on two rule descriptions sounding alike.
| first run | now | |
|---|---|---|
| Their patterns turned into a probe | 146 of 146 | 146 of 146 |
| Both corpora fire on the same construct | 6 | 10 |
| Theirs fires, nothing here does | 38 | 34 |
Lines their analyser fired on (# nosemgrep: markers) |
631 | 631 |
| β¦where a checker here also fires | 20 | 20 |
The agreement moved from 6 to 10 because the gap it named got closed, not because anything was re-scored β the first six were all reached by the two Python rules about executing input, and not one by an Odoo rule.
Most of that gap is a different subject, not a missing rule: theirs is about security
and this one is about ORM correctness. Two entries were neither, and are now rules β
odoo.commit-inside-a-loop-splits-the-transaction and
odoo.sql-needs-query-parameters. The commit rule fires exactly once in 229,600 lines
of OpenSPP, inside a loop their analyser had flagged too: # nosemgrep: odoo-commit-in-loop β batch consent expiry cron. Two analysers, one construct, and a
person who had already decided.
They do not anchor to the same line, though, and that is worth knowing. Semgrep matches
the whole loop and its marker sits on the for; this checker reports the commit() ten
lines inside it. scan_source only reads a marker on the finding's own line, so it does
not carry across β deliberately, because a marker that silences findings it was not
written about is the mute button this project will not build.
Every version claim is checked against two kinds of evidence
python corpus/check_sources.py # what the docs describe
python corpus/check_sources.py --source # what the source defines
Each Odoo rule declares the series it holds for. The first mode checks that claim against the documentation: 42 of 45 versioned rule-series cite a page that exists and names the API, 2 more are tutorials that moved while the API stayed, and 1 is not supported by any 16.0 page.
Documentation has a blind spot, and the corpus had fallen into it β a function Odoo
shipped and never described reads as missing. So the second mode reads odoo/odoo
itself and asks whether the API is defined in that series' module: def float_compare( in odoo/tools/float_utils.py, not the word anywhere on a page.
| Rule-series defined in source across 16.0, 17.0 and 18.0 | 54 of 54 |
That closes it. No version claim in this corpus rests on an assertion, and the one
documentation gap now carries a measurement instead of a belief: float_compare's
signature is byte-identical in all three series. Neither mode is a test run β a
definition says the API existed and was spelled the same way, not that it behaved
identically β and both say so in their own output.
Authoring-time and the only thing here that touches the network. Nothing at query time depends on it.
Is every rule findable?
A rule the retrieval never surfaces is unreachable whatever it says. Every rule carries a probe β one question phrased the way somebody meets that problem β and
nexa eval --retrieval
asks each rule its own probe and reports whether it comes back. No generation, so it needs only the embedding model and finishes in seconds.
All 44 rules are retrievable at the operating TOP_K of 6, and at top 3. Two were
not, and both failed the way a rule does: correctly worded, in the vocabulary of
somebody who already knows the answer. no-monkey-patching did not answer "change
what a core sale.order method does"; unknown-not-any-at-boundaries did not answer
"what type should a webhook payload have". Reworded, measured again, both found.
The report states its own limits. Pinned rules are prepended regardless of score, so
ranking against them measures the pin; JavaScript has 7 rules against a TOP_K of 6,
so passing there is nearly free, and the header says so rather than printing 7/7 and
letting it read as a result.
And what does nexa check actually report?
Every number above was measured on nexa ask. nexa check is the command you
would point at your own file, and nothing measured it β it was assumed to inherit
ask's numbers because it ends in the same call.
nexa eval --check
runs a fixed set of files the way nexa eval runs a fixed set of questions, and
adds the four things only a file can produce: which corpus it was routed to, how
much of it fitted, what the surroundings on disk let the context layer say, and
whether the answer mentions the file at all.
Five files, five runs each:
| Findings reported about the files | 23, in 5 of 5 files |
| β¦which is what the corpus can find in them | 23 β nexa fieldtest, no model |
| WARN | 18 of 25, 95% CI 52β86% |
| REJECT | 7 of 25, 95% CI 14β48% |
| PASS | 0 of 25 |
| Same verdict every run | 4 of 5 files |
| Rules the model cited that no checker found | 1, across 25 reviews |
The deterministic half does everything it is capable of. Those 23 findings are
exactly what nexa fieldtest extracts from the same five files with no model
installed: an incomplete @api.depends on three stored computes, an
@api.constrains reading two fields it does not declare, shell=True, a bare
except, a mutable default argument, JSON.parse unchecked, a rejected promise
nothing catches. Each carries a line number and none depends on what a 7B says.
The first run of this eval reported none of them, because every layer ran over the model's reply and nothing had ever read the file.
The generative half adds close to nothing, and now there is a number. Across 25 reviews the model cited exactly one rule the checkers had not already found. It does make the answers more about the file than they were β feeding it the findings raised the symbols it names from 34 to 90 across the set β but what it adds to the corpus's own verdict is one citation in twenty-five.
That is worth knowing rather than worth hiding. It says where to spend effort: another rule in the corpus is worth more than another prompt.
Coverage is evidence. fsm_visit.py is 7,929 characters against a
6,000-character budget, so nine of its twenty symbols do not fit β but the excerpt
is chosen by symbol and the flagged ones go first, so _check_no_overlap, its
last method and the home of three of its six findings, is reviewed rather than cut
off 1,900 characters early. What is left out is named, and the verdict says so:
WARN (INSUFFICIENT EVIDENCE: 9 of 20 symbols were not reviewed)
That file used to PASS on five runs of five, because nothing could see it.
How it got here. The first run of this eval found check the wrong shape in two
ways, neither about the model's quality. The checkers ran over the model's answer,
so the corpus could find six things in a file and the command reported none of them.
And the excerpt was the first 6,000 characters, which cut one subject 1,900 characters
above the method carrying half its findings. Both are fixed, and the table above is
what that is worth.
subjects/ is committed so the run repeats. Point it at your own tree for the
half a written-for-the-measurement set cannot tell you:
nexa eval --check addons/my_module --limit 6
Environment
Everything below is optional; unset, each is the default it has always been.
| Variable | Effect |
|---|---|
OLLAMA_HOST |
Where Ollama is. 11500, box:11434 or https://gpu.lan all work β the same spellings the server accepts. Default http://127.0.0.1:11434; nexa doctor reports which it used and whether you set it. |
NEXA_EVIDENCE=0 |
Write no evidence bundles. |
NEXA_EVIDENCE_DIR |
Where bundles go. Default bin/evidence/. |
NEXA_EVIDENCE_KEEP |
How many to keep. Default 200, pruned oldest first. |
LOCALAI_TELEMETRY=0 |
Record no telemetry at all. |
LOCALAI_TELEMETRY_FILE |
Where the JSONL goes. Default bin/telemetry.jsonl. |
LOCALAI_TELEMETRY_CONTENT=1 |
Record question and answer text. Off by default: this runs on a machine used for client work. |
NO_COLOR / TERM=dumb |
No ANSI escapes. Colour is also off automatically when stdout is not a terminal. |
NEXA_PYTHON |
Which interpreter the ./nexa launcher uses. |
Requirements
- NVIDIA GPU with 6 GB VRAM or more (developed on an RTX A3000 Laptop)
- 32 GB system RAM for the 30B escalation model; 16 GB works without it
- Python 3.10+ β stdlib only, nothing to pip install
- Ollama 0.30 or later
- ~25 GB free disk
Optional: LLM Workflow Router for
nexa run. nexa ask works without it.
Documentation
- INSTALL.md β setup from scratch
- ARCHITECTURE.md β how it works, every design decision and why it was made, and an honest account of what it cannot do
- LICENSING.md β what is free and what needs a license
License
Source-available under the PolyForm Noncommercial License 1.0.0 β free to read, run, modify and evaluate for any noncommercial purpose. Commercial and client-work use requires a license; see LICENSING.md.
Nexa runs entirely on your own hardware, so a commercial license carries no per-seat metering and no dependency on anything of mine staying online. Nothing phones home.
The models Nexa orchestrates are pulled from Ollama's registry and carry their own upstream licenses.
Copyright Β© 2026 Doby Baxter
What you get
Nexa is built to close one gap precisely: the distance between an answer that sounds right and an answer that has been checked. Every response is grounded in your rules, verified against them by nine independent layers, and returned with its own coverage stated.
It is free, it runs entirely offline, your client code never leaves the machine, and the standard it applies is the one you wrote β not a general impression of good practice.
Read the output. Two parts are worth more than the verdict itself: the model's own "Not handled:" section, and the "Not checked" list the layers print beneath it.
The full design record β every decision, the evidence behind it, and the trade-offs taken β is in ARCHITECTURE.md.