Keel local

Overview / Documentation / Tutorial

Tutorial

This walks a fresh clone up to a running appliance answering questions about your own documents, with the controls visible at each step. Everything here runs on one machine. Nothing reaches an external service, and the last section turns the network off entirely and shows the same commands still working.

Reading time is about fifteen minutes. Running it takes about the same, plus a model download if you want generated answers rather than retrieval alone.

Before you start

1. Install

git clone https://github.com/BlakeR-M/keel.git
cd keel
python -m venv .venv

source .venv/bin/activate     # macOS and Linux
.venv\Scriptsctivate        # Windows

pip install -e ".[dev]"

Confirm the appliance can see itself:

keel status

That prints the profile, the data directory, the air-gap state, whether a model is reachable, the corpus counts and the ledger head. A fresh install reports an empty store, which is correct.

Run the suite while you are here. It is the fastest way to know the clone is sound:

.venv/bin/python -m pytest -m "not integration"

2. Load the fixture corpus

The repository ships five small documents that hold nothing real: a council procurement policy, a clinic data-handling policy, an HR salary-bands document tagged hr, an operations note, and a supplier note with a prompt injection planted in it on purpose.

keel ingest --manifest fixtures/corpus.yaml

Read the output rather than skipping it. Three things happened:

Quarantined chunks stay out of retrieval until a person releases them from the admin page. That is the intended posture: a suspect document is visible to an operator and invisible to the model.

3. Ask a question, then ask one you are not entitled to

keel ask 'How many written quotes does a $20,000 purchase need at Northbank Council?' --tags public

You get an answer with a citation to the exact chunk it came from. Now the restricted one, as a user holding only the public tag:

keel ask 'What is the confidential review code for the 2026 pay round?' --tags public

Keel says the sources do not cover the question. Read what that refusal costs: nothing entitled cleared the relevance line, so no prompt was built and no model call was made at all. There is no generated text for a cleverly worded follow-up to steer.

Add the tag and ask again:

keel ask 'What is the confidential review code for the 2026 pay round?' --tags public,hr

Now it answers, with a citation.

The difference between those two runs is not a filter applied to the answer. The entitlement filter runs inside the BM25 query and inside the vector query, before the two result sets are fused and before the reranker sees anything, so an unentitled chunk was never a candidate. A second check runs at the generation boundary, added as finding A7 of the security review, so a retrieval bug on its own is not enough to put a restricted chunk in front of the model.

4. Run the agent, and approve its write

Agent mode lets the model call five typed tools under a policy that is written down rather than implied: search_docs, calculator, sql_readonly, http_get and create_ticket.

keel agent "Create a support ticket titled 'Printer down' saying the level 2 printer is jammed."

The step table shows what the model called and what came back. The ticket does not exist yet. Ticket creation is a write tool, so it queued:

keel approvals list --status pending
keel approvals approve 1 --by owner

The policy is re-checked at the moment of execution rather than at the moment of approval, which was finding P3 of the review. An approval cannot outlive the policy that permitted it: tighten the policy after someone approves an action and the approval turns into a recorded refusal instead of a write.

The read-only SQL tool is worth trying as well. It runs under a SQLite authorizer with statement and result-size caps, so it can read and cannot write, and it cannot be talked into allocating its way through the machine's memory.

http_get is the one tool that reaches outside the box, so it is the one to look at closely. It is bound to a host allowlist, an empty allowlist refuses every host, and with KEEL_AIRGAP=1 it refuses before any connection is attempted at all. Section 8 turns that on.

5. Verify the audit trail

keel verify-ledger

Every request, retrieval, answer, tool call and approval appended a row that hashes a canonical JSON array of the chained fields. Because the field boundaries are explicit in the hash material, moving a character from one field into the next changes the hash rather than producing the same one, which was finding L3.

Export it and verify the file on its own, which is the shape an assessor asks for:

keel verify-ledger --export ledger.jsonl

That writes the ledger as JSON lines and then verifies the written file offline with the same function, so the export is checked rather than assumed. The format is strict JSON: it refuses to write NaN or Infinity, so a strict parser on the other end never chokes on it, and a file resaved with a byte-order mark still verifies. The inference log exports separately with keel export-log.

6. Bring your own documents

Point ingest at one or more files, at an http or https URL, or at a manifest. PDF, DOCX, Markdown, HTML and plain text all load.

keel ingest ./handbook.pdf --tags public
keel ingest ./salary-bands.docx --tags hr,finance
keel ingest ./policy-a.md ./policy-b.md --tags public
keel ingest https://example.gov.au/procurement-policy --tags public

Tags are the whole access-control model, so decide them deliberately. A tag is a capability: a user holding hr can retrieve any chunk tagged hr. Re-ingesting the same bytes reports a duplicate and adds nothing, and re-ingesting with different --tags retags the stored document and its chunks in one transaction rather than silently doing nothing, which was finding A2.

For anything larger than a handful of files, write a manifest instead: a YAML list of documents with their paths, titles and tags, which keeps the tagging decision in a file you can review rather than in shell history. Copy fixtures/corpus.yaml and edit it, then run keel ingest --manifest your-corpus.yaml.

Turn on the language-model judge at ingest when the documents come from somewhere you do not control:

keel ingest --manifest supplier-notes.yaml --judge

The heuristics catch trigger words and structure. The judge catches paraphrased instructions that carry no trigger word, which is the open medium finding in the review, recorded honestly rather than quietly closed.

7. Serve it

keel serve                    # http://127.0.0.1:8400

On loopback the user picker on the demonstration page is honoured as given, because loopback identity is the operator's own login. Beyond loopback nothing in a request body or query string can name a user or a tag. Identity comes from an authenticating reverse proxy through X-Keel-User and X-Keel-Tags, honoured only when the request also carries X-Keel-Proxy-Token matching KEEL_PROXY_TOKEN, and every other request runs as public. That was finding A1. Set the admin token as well before you bind to anything other than loopback:

export KEEL_ADMIN_TOKEN="$(python -c 'import secrets; print(secrets.token_hex(32))')"
export KEEL_HOST=0.0.0.0
keel serve

The admin page carries the quarantine list, the approval queue, ledger verification and export, and a fourteen-day trend. See the web documentation for the routes and the identity model in full.

8. Turn the network off

export KEEL_AIRGAP=1
keel status

The status line now reports air-gap on. From here every outbound connection to a host outside the allow list is refused before a packet leaves, at five layers: name resolution, the socket, the event loop, urllib and httpx. Name resolution is guarded on its own because a lookup of <secret>.attacker.example carries the secret to a name server whether or not the connection ever happens, which was finding G1.

Loopback stays reachable, so a model server on the same machine keeps working. Name a compose sibling or another allowed host explicitly:

export KEEL_AIRGAP_ALLOW_HOSTS=llama

Enabling air-gap mode also sets HF_HUB_OFFLINE=1 and TRANSFORMERS_OFFLINE=1, so the embedding and reranking models read the local cache and skip their metadata requests. Warm that cache once with the network on, and every later run works with the cable out.

Confirm it for yourself rather than taking the paragraph on faith. The overview page runs exactly this guard against any host you name, and reports what each layer did.

9. Measure the answers

keel eval

The golden set runs through the production answer path and scores retrieval, groundedness, relevance, refusals, leak strings and judged quality, then writes HTML and JSON reports. The gate fails the run when a metric drops past its threshold, and continuous integration runs it on every change, so answer quality is measured rather than asserted. See the evaluation guide.

Where to go next