Skip to content

LLM Layer

nah can optionally consult an LLM for decisions that need judgment after deterministic classification.

Tool call → nah (deterministic) → LLM (optional) → Claude Code permissions → execute

The deterministic layer always runs first. Unified ask-refinement only sees eligible ask decisions. Script inspection can call the LLM as a veto path, and write-like tools can call the LLM for safety + intent review. The LLM cannot relax deterministic blocks. If no LLM is configured or available, the deterministic decision stands.

Providers

nah supports 6 LLM providers. Configure one or more in cascade order -- first success wins.

Provider API Default model Auth env var
ollama Chat API (/api/chat) qwen3.5:9b (none -- local)
openrouter OpenAI-compatible google/gemini-3.1-flash-lite-preview OPENROUTER_API_KEY
openai Responses API (/v1/responses) gpt-5.3-codex OPENAI_API_KEY
azure Azure OpenAI Responses/chat completions (deployment-dependent) AZURE_OPENAI_API_KEY
anthropic Messages API (/v1/messages) claude-haiku-4-5 ANTHROPIC_API_KEY
cortex Snowflake Cortex REST claude-haiku-4-5 SNOWFLAKE_PAT

All providers use urllib.request (stdlib) -- no external HTTP dependencies.

Configuration

# ~/.config/nah/config.yaml
llm:
  mode: on
  providers: [ollama, openrouter]   # cascade order
  ollama:
    url: http://localhost:11434/api/chat
    model: qwen3.5:9b
    timeout: 10
  openrouter:
    url: https://openrouter.ai/api/v1/chat/completions
    key_env: OPENROUTER_API_KEY
    model: google/gemini-3.1-flash-lite-preview
    timeout: 10

llm.enabled: true is still accepted for backward compatibility, but llm.mode: on is the current form.

LLM provider setup lives in global config. Store environment-variable names such as key_env: OPENROUTER_API_KEY, not raw API keys. nah install is reserved for installing nah into guarded runtimes: Claude Code, plus the beta bash/zsh terminal guard. Install nah[config] or inject PyYAML into pipx when you want nah to read YAML config files.

Provider examples

llm:
  mode: on
  providers: [ollama]
  ollama:
    url: http://localhost:11434/api/chat
    model: qwen3.5:9b
    timeout: 10
llm:
  mode: on
  providers: [openrouter]
  openrouter:
    url: https://openrouter.ai/api/v1/chat/completions
    key_env: OPENROUTER_API_KEY
    model: google/gemini-3.1-flash-lite-preview
llm:
  mode: on
  providers: [openai]
  openai:
    url: https://api.openai.com/v1/responses
    key_env: OPENAI_API_KEY
    model: gpt-5.3-codex
llm:
  mode: on
  providers: [azure]
  azure:
    url: https://YOUR-RESOURCE-NAME.openai.azure.com/openai/v1/responses
    key_env: AZURE_OPENAI_API_KEY
    model: your-deployment-name

Azure uses api-key header auth, not bearer auth. The url is required because it depends on your Azure resource and deployment. For chat-completions deployments, set url to the deployment's /chat/completions endpoint; nah selects the payload shape from the URL.

llm:
  mode: on
  providers: [anthropic]
  anthropic:
    url: https://api.anthropic.com/v1/messages
    key_env: ANTHROPIC_API_KEY
    model: claude-haiku-4-5
llm:
  mode: on
  providers: [cortex]
  cortex:
    account: myorg-myaccount   # or set SNOWFLAKE_ACCOUNT env var
    key_env: SNOWFLAKE_PAT
    model: claude-haiku-4-5

LLM options

eligible

Control which ask categories route to the LLM:

llm:
  eligible: default    # strict | default | all

Or use an explicit list:

llm:
  eligible:
    - strict
    - git_discard
    - composition      # opt in to composition asks
    - sensitive        # opt in to sensitive context asks

strict routes unknown, lang_exec, and non-sensitive context asks to the LLM.

default adds package_uninstall, container_exec, browser_exec, and agent_exec_read. It keeps process_signal, service writes, destructive container/service actions, git discard/history/remote writes, agent write/remote/server/bypass actions, composition, and sensitive prompts human-gated by default.

Explicit lists can combine presets and action types. composition and sensitive are gates: add them explicitly, or use top-level eligible: all, if you want those asks routed to the LLM.

Provider responses of block are treated as uncertain, so the LLM can allow an eligible ask or leave it as an ask; it cannot block through ask-refinement.

LLM responses include a short prompt-safe reasoning summary and a longer reasoning_long explanation for observability. Claude-visible prompts use the short summary; structured logs and nah test can show the longer explanation for debugging.

Target-specific LLM policy

The global provider cascade and credentials are shared across targets. Per-target overrides can change whether a runtime uses the LLM and which decisions are eligible:

llm:
  mode: on
  providers: [openrouter]
  openrouter:
    key_env: OPENROUTER_API_KEY
    model: google/gemini-3.1-flash-lite-preview

targets:
  claude:
    llm:
      mode: on
  bash:
    llm:
      mode: off
  zsh:
    llm:
      mode: off

Bash and zsh are beta terminal-guard targets. They default to LLM mode off even when global LLM mode is on. That keeps human terminal commands local by default. Turn it on only with an explicit target override such as targets.bash.llm.mode: on.

Project .nah.yaml files can tighten target LLM policy, such as turning a target off, but they cannot configure provider credentials or silently loosen global policy unless trust_project_config: true is set globally.

Write/Edit review

When LLM mode is enabled, Write/Edit/MultiEdit/NotebookEdit operations are reviewed after deterministic checks. Deterministic block results skip the LLM and stay blocked.

For deterministic allow results, the LLM can still escalate to ask when the content looks risky. This catches suspicious write content that deterministic patterns miss. Provider block responses are treated as non-allow, so write review never produces a final block.

For deterministic ask results, the only relaxable class is a project-boundary ask:

  • <Tool> outside project: ...
  • <Tool> outside project (no git root): ...

If the LLM returns allow for one of those asks, nah records an allow decision. Whether nah emits an automatic allow to Claude Code is still controlled by active_allow; if Write/Edit is not active-allowed, Claude Code's normal permission prompt handles the tool.

These ask classes stay human-gated even if the LLM returns allow:

  • hook self-protection
  • nah config self-protection
  • sensitive paths
  • deterministic content-pattern asks
  • malformed or unparseable write-like payloads

The write-review prompt includes the tool, target path, working directory, inside-project status, deterministic decision and reason, the write/edit content with secret redaction, and recent transcript context. The LLM is instructed to allow only narrow edits that match recent user intent and do not add or expose literal credentials, exfiltrate data, weaken auth, add persistence, alter hooks, or bypass safety controls.

context_chars

How much conversation transcript context to include in the LLM prompt:

llm:
  context_chars: 12000  # default: 12000 characters of recent transcript

Set to 0 to disable transcript context entirely.

The transcript is read from Claude Code's JSONL conversation file. It includes user/assistant messages and tool use summaries, wrapped with anti-injection framing.

How the cascade works

  1. nah tries each provider in the order listed in providers:
  2. If a provider returns allow, that decision is used
  3. If a provider returns uncertain, the cascade stops (doesn't try the next provider)
  4. If a provider errors (timeout, auth failure), nah tries the next provider
  5. If all providers fail, the deterministic decision stands; for ask-refinement, that means the decision stays ask

Provider uncertain responses stop the cascade. In ask-refinement they leave the decision as ask; in write-like review they are treated as non-allow, so risky content stays human-gated.

Testing

nah test "python3 -c 'import os; os.system(\"rm -rf /\")'"
nah test --target bash -- "python3 -c 'print(1)'"
# Shows: LLM eligible: yes/no, LLM decision (if configured)

The nah test command shows LLM eligibility and, if enabled, makes a live LLM call so you can verify the full pipeline.