AI agent¶
Requires the [agent] extra. A base install remains a complete Kubernetes TUI
that starts, watches, and drives the cluster as before.
Press Ctrl-A to open the agent panel. It answers questions about the current cluster, inspects it
through bounded read-only tools, and can drive the TUI, but never writes without
your keystroke.
What a real turn does
- ContextCurrent view, namespace, selection, and filter.
- ReadBounded tools gather manifests, events, logs, or diagnoses.
- CiteEvidence references remain selectable and validated.
- DriveNavigation can change; writes still stop at confirmation.
Installing the agent¶
The agent is an optional extra. A base install has no provider transport or
keychain integration, so Ctrl-A is unavailable and :ai is not registered.
uv tool install "korvid[agent]" # or: pipx install "korvid[agent]"
uv tool install "korvid[all]" # agent, MCP, observability
Nothing about it loads until the agent is composed. If agent.active names a
configured profile but the extra is missing, startup fails with the exact
install command rather than silently disabling it.
A local Ollama endpoint keeps every request on the machine, and no API key
exists to leak. For an endpoint behind a private CA set network.ca_bundle;
verification can never be disabled. The air-gapped guide has the
rest of the no-egress story.
From prompt to cited answer¶
A turn sees your screen context — view, namespace, selected resource, active
filter — and answers by calling bounded read-only tools: manifest, log, event
and listing reads, plus compound diagnostics that return projected evidence
instead of a raw dump. diagnose_pod collects container states, the owner
chain, warning events and targeted log excerpts in one deterministic call;
diagnose_service and diagnose_pvc do the same for EndpointSlice readiness
and unresolved claims. Results are capped at 8,000 characters, manifests are
shrunk structurally so they stay parseable, and Secret data is masked before
the model sees it.
Each successful read mints a numbered evidence reference ([E1], [E2], …)
that the answer cites, validated against what was actually fetched rather than
merely asserted. Navigable citations open their source view. Compound
diagnostics remain validated evidence but have no single destination — no
screen holds the whole report. Screen actions and writes mint none.
It can also drive the TUI — navigate, filter, drill down, open the log or describe pane. korvid detects the cluster's cloud provider (AKS, EKS, GKE) from node metadata, best-effort; an RBAC-limited, bare-metal, or local cluster detects as "unknown" and nothing changes.
From proposal to write¶
The agent can request write operations — delete, scale, rollout restart, and
(where pods/resize exists, Kubernetes 1.35+) in-place pod resize — but never
executes one. Every request opens the same confirmation dialog a direct
keybinding does (marked ⚠ in the tool log): the proposal stays inert until a
fresh user keystroke in that dialog approves it, and an unanswered dialog
expires without executing anything. Every executed write — yours or the
agent's — goes through the same fail-closed
audit log: if the audit entry cannot be
written, the write is blocked before it happens.
Direct control and the conversation¶
The agent and the keyboard drive one workspace: what you have selected when
you submit is what the turn is asked about, and the agent moves the panes your
keys move. Pressing a normal key mid-turn keeps working, and the next turn
starts where you left it. Ctrl-A toggles the panel's visibility only.
:ctx hands the cluster back and clears the evidence ledger, so a citation
minted against the old cluster cannot resolve to a same-named object in the
new one.
Ctrl-X stops a running turn, leaving the partial answer marked
⏹ interrupted; typing a new prompt mid-turn is interrupt-and-submit. Both
respect the write gate: a pending approval dialog is dismissed unexecuted,
while an approved write finishes and is audited.
Follow mode mirrors each successful read: a listing navigates the view,
diagnose_* or a resource read opens the describe pane, a log read opens the
live log pane. On by default — agent.follow: false, or :ai follow off /
:ai follow on. A mirror is refused while an approval dialog or a describe
screen you are reading is open.
Inspecting what the agent sends¶
:ai payload opens a read-only view of the exact sanitized request most
recently sent to the provider — the literal payload, not a re-derived
approximation. Secret values, the
kubectl.kubernetes.io/last-applied-configuration annotation, and
credential-shaped text are masked before the inspector, or the network, sees
them.
The payload is sanitized, not anonymized — resource names, namespaces and
labels still appear in it. Press e to export it to a private, 0o600 file.
The threat model has the boundary and residual risks.
Turn latency diagnostics¶
Every turn is timed against a single monotonic clock, so you can see where a slow turn spent its time without guessing. The measurement is provider-neutral — the same for OpenAI, a local Ollama model, or the offline test provider — and it never reads a prompt, a tool argument, a tool result, or any provider payload.
Live phase in the status line. While a turn runs, the status line names the phase it is in rather than a generic spinner:
- waiting for model — preparing or running the first provider round.
- running
<tool>— a tool call is executing. Its human-readable label can include arguments such as pod or namespace; diagnostic logs retain only the registry tool name. - composing answer — a later provider round is streaming the answer after a tool result went back.
Compact summary on the finished turn. When a turn ends, one dim line is added to the transcript, for example:
2 model rounds · model 3.4s · tools 1.1s · ↑1.8k ↓200 tok · prompt 2.0s · generate 1.0s · other wait 0.4s
It reports the number of provider rounds and the wall time spent in the model
and in tools. Token counts and the prompt evaluation, generation, and other-wait
split appear only when native provider metrics include those measurements.
Other wait includes transport and queueing. A failed or interrupted turn is never shown as a clean success:
its summary is prefixed with failed · or interrupted · so the timings are
never mistaken for a completed answer's.
Structured logs. The same snapshot is emitted once per turn to the
korvid.agent.diagnostics logger at INFO, keyed by a locally generated,
non-sensitive correlation ID. To record JSON lines without disturbing the
TUI, launch it with a file handler attached only to this logger:
python -c 'import logging, runpy; log = logging.getLogger("korvid.agent.diagnostics"); log.setLevel(logging.INFO); log.addHandler(logging.FileHandler("korvid-agent-timing.log", encoding="utf-8")); log.propagate = False; runpy.run_module("korvid", run_name="__main__")'
Each record includes the turn outcome and total duration, plus per-round
request_started_at_seconds, request_acknowledged_at_seconds,
first_event_at_seconds, and first_content_at_seconds. These are monotonic
offsets from that round's start. Dispatch is only an attempt; acknowledgement
records when transport acceptance was proven, not a socket-write timestamp.
The first event may be reasoning or usage; first content is a nonempty text
or tool-call event. Missing boundaries and optional metrics remain unknown,
not measured zeroes. other wait is shown only for rounds with provider totals
and includes local preparation/transport overhead as well as possible queueing.
Only an explicit allowlist reaches the log record — durations, token counts, round numbers, registry tool names, the per-tool success flag, the correlation ID, and the turn outcome. There is no generic object serialization, so a prompt, a reasoning trace, a tool argument or result, a Kubernetes object, a credential, or a raw provider payload can never ride along.
Provider-reported timings (Ollama). korvid always reports its own monotonic
round and tool timings. The panel header tracks ordinary token usage separately;
token counts in the diagnostic summary require optional native metrics.
Ollama additionally reports native total, load, prompt-evaluation, and generation
durations. There is no native queue-duration counter: other wait is inferred
from local round time minus provider total and can include transport or adapter
overhead. The LiteLLM adapter would
otherwise discard these durations, so korvid captures each request's terminal
HTTP frame immediately before LiteLLM transforms it, reusing LiteLLM's own JSON
decode for streaming and non-streaming responses rather than buffering a second
copy. Only the six allowlisted numeric counters are retained; the model, prompt,
response text, context, and every other raw field are discarded. If an
Ollama-compatible endpoint omits a counter, that metric is simply absent while
korvid's local monotonic timings remain available.
What counts as a finished answer¶
A streamed answer is accepted only when the provider's own protocol says it
finished — [DONE], done: true, or a finish_reason the provider sent —
and reading stops at that point. A stream that ends without it fails the
turn: the text that arrived stays on screen, but its tool calls and token
counts are discarded rather than treated as a complete response. What an
adapter buffers before the turn's budget can see it is bounded as well: a
call's arguments, how many calls one response may open, reasoning kept for
the next request, and the wizard's connection-test reply. The reply bound
is a refusal, not a trim: an answer past it fails the test rather than
being shown cut short.
Self-hosted endpoints and proxies¶
A gateway that streams tokens but forwards no stop signal is refused, and
:ai's connection test is where you will see it first. For an
OpenAI-compatible endpoint the signal is the finish_reason the provider
itself sent — the body ending cleanly is not one, and neither is a bare
data: [DONE], which some proxies add themselves. Ollama's native API
sends "done": true; the Copilot dialect sends the wire's [DONE].
To see what your gateway really forwards, stream one request through it:
curl -N -H 'Content-Type: application/json' \
-d '{"model":"MODEL","stream":true,"messages":[{"role":"user","content":"ok"}]}' \
https://gateway.internal/v1/chat/completions | grep -c finish_reason
0 means the field is being dropped or rewritten in transit: upgrade the
proxy, or turn off any middleware that rebuilds streamed chunks. Until
then, a non-streaming-only backend is unaffected — the rule applies to
streams, which is where "the connection died" and "the answer ended" are
otherwise indistinguishable.
Connect a provider¶
:ai (alias :agent) is the quickest path. On a first run it opens the setup
wizard — model, authentication, a live test call — saved to
~/.config/korvid/config.yaml; once profiles exist it opens the profile
manager, where Enter activates the highlighted profile, a/e/d add, edit
and delete it, and t sets the tier. :model prints the active model and
:model <name> switches it; :ai off releases the connection without
discarding the config.
When the test call fails, the wizard stays open and shows korvid's own
sentence for what happened — a refused credential, a model the provider does
not know, an answer that never finished. A failure korvid has no written
words for is reported as "the connection test failed" and the underlying
reason is written to the log instead: those messages come from the library,
the keychain or the endpoint, and they routinely quote the key that was
refused. Ctrl+R retries with everything the wizard already collected.
A connection is a profile under agent.profiles.<name>, and agent.active
names the one in use — editing that key switches profiles outside the TUI.
Every model reference is <prefix>/<tag>: the prefix picks the transport
route, the tag names the model. Colon-form references (ollama:qwen3:8b) are
not accepted, because a tag can itself contain a colon.
agent:
active: local
profiles:
local: # keyless Ollama on this machine
model: ollama/qwen3:8b
endpoint: http://localhost:11434
auth:
method: none
options:
num_ctx: 32768
gpt: # key stays in the environment
model: openai/gpt-4o
auth:
method: environment
key: OPENAI_API_KEY
azure-work: # Entra ID; needs [entra]
model: azure/gpt-4.1
endpoint: https://my-hub.openai.azure.com/openai/deployments/gpt-4.1
auth:
method: provider-default
auth.method is one of five values. The secret never belongs in the config
file: auth.key is a name — of an environment variable, or a keychain
entry — and the other three methods ignore it.
| Method | Meaning |
|---|---|
environment |
Key in the environment variable named by auth.key; refused when it is unset. |
keyring |
Key in the OS keychain entry named by auth.key; falls back to profile.model if absent. |
provider-default |
A declared credential chain for the prefix — Entra ID, plus anything on the korvid.credential entry point; azure needs [entra]. |
device-login |
Interactive device-code sign-in, used by the github-copilot flow. |
none |
No credential; requires endpoint, since a keyless request without one goes to whatever host the SDK defaults to. |
| Prefix | Notes |
|---|---|
openai, anthropic |
openai also covers any OpenAI-compatible server; set endpoint for a self-hosted one. |
azure |
endpoint is the deployment URL. |
Ollama (ollama) |
options.native_thinking: true selects native /api/chat; the same options mapping tunes num_ctx, temperature, seed, think, keep_alive, num_predict. |
| Any LiteLLM-supported prefix | 2,000+ models ship in the bundled catalog; use any prefix directly. |
options carries model parameters only. model, messages, stream,
tools, tool_choice, base_url, api_key, custom_llm_provider, any
credential-shaped key and LiteLLM's controls (mock_*, fallbacks, callbacks,
litellm_*) are korvid's, and are dropped from a profile: none can re-route a
request, mute the agent's tools or fabricate an answer.
Provider connections are configured only through agent.profiles; agent.active
selects one, and active: null disables the agent. Model tuning belongs in the
selected profile's options. Unsupported root or agent settings are
configuration errors rather than alternate input formats.
Configurations from versions that accepted flat agent.provider,
agent.model, agent.base_url, agent.api_key_env, agent.auth,
agent.ollama, agent.options, or agent.enabled settings must be moved
manually; they are no longer migrated on load.
First distinguish the two shapes v0.4.1 accepted:
| v0.4.1 file shape | Upgrade action |
|---|---|
agent.profiles was present |
v0.4.1 kept agent.active and agent.profiles and ignored flat provider, model, base_url, api_key_env, auth, ollama, options, and enabled. Delete those flat keys; do not apply the conversions below. |
agent.profiles was absent |
Only this shape uses the conversions below to build a profile from the flat connection. |
Before alias or authentication handling, v0.4.1 stripped surrounding
whitespace, lowercased the provider, and collapsed every run of -, _, or
. to one -. GitHub.Copilot and github_copilot therefore both became
github-copilot; openai.compat became openai-compat before the alias table
was applied. Reproduce that canonical provider spelling first because
v0.5.0's profile-prefix normalization does not fold dots.
The v0.4.1 parser translated these provider aliases exactly:
Legacy provider |
Profile model |
|---|---|
openai-compat, openai, vllm, github, anthropic, claude |
openai/<model> |
Every other canonical provider became <provider>/<model>. The remaining
flat inputs had these ordering semantics:
| Legacy flat input | v0.4.1 result to reproduce in the profile |
|---|---|
agent.base_url |
Copy to profile endpoint, except for the Azure cases below. |
agent.api_key_env and agent.auth |
Build profile auth using the explicit and inferred mappings below. |
agent.ollama |
Normalize the supported values below, then inject native_api: true and native_thinking: true for an Ollama provider. Only native_thinking selects that transport in v0.5.0. |
agent.options |
When it was a mapping, v0.4.1 copied it into profile options after normalized agent.ollama values and injected native_api and native_thinking; its value overrode the same key. Preserve that final, last-wins mapping, subject to v0.5.0's reserved-option validation. |
agent.enabled |
enabled: false became active: null; the generated profile remains present so it can be selected later. Every other value left it active. |
Map provider-specific legacy URLs to v0.5.0 endpoints without changing where their requests are sent:
| Legacy input | Profile result |
|---|---|
provider: azure with base_url: https://<resource>.openai.azure.com/openai/v1 |
Use resource-root endpoint: https://<resource>.openai.azure.com. |
provider: azure with base_url: https://<resource>.openai.azure.com/openai/deployments/<name> |
Retain the complete URL as endpoint: https://<resource>.openai.azure.com/openai/deployments/<name>; do not convert it to a resource root. |
provider: azure with bare base_url: https://<resource>.openai.azure.com |
The same resource-root endpoint: https://<resource>.openai.azure.com. |
Any non-azure provider |
Copy the complete base_url to endpoint unchanged; do not rewrite /openai/... paths. |
When v0.4.1 automatically migrated a flat Azure deployment URL, it could
persist a named profile with resource-root
endpoint: https://<resource>.openai.azure.com plus
options.azure_deployment: <name>. v0.5.0 accepts that profile, but filters
the option and uses the profile's model tag as the deployment. Before
upgrading, set
endpoint: https://<resource>.openai.azure.com/openai/deployments/<name> and
remove options.azure_deployment; this preserves <name> when it differs
from the model tag.
It translated explicit legacy authentication methods as follows:
Legacy auth.method |
Profile auth.method |
|---|---|
api_key |
environment |
entra |
provider-default |
device-login |
device-login |
none |
none |
Without an explicit legacy auth.method, github-copilot becomes
device-login; every other provider becomes environment when
api_key_env is set, or none otherwise. Whenever the resulting method is
environment, copy agent.api_key_env: NAME to profile auth.key: NAME.
For every other resulting method, v0.4.1 ignored api_key_env; do not add
auth.key.
Normalize each legacy agent.ollama value before placing it in profile
options; copying a quoted numeric or a num_ctx/seed float verbatim can
change the effective request:
Legacy agent.ollama key |
Store in profile options |
|---|---|
num_ctx, seed |
For an integer, finite float, or numeric string, store int(value); drop booleans, non-finite values, and values int cannot convert. seed: 0 remains valid. |
temperature |
For an integer, finite float, or numeric string, store float(value); drop booleans, non-finite values, and values float cannot convert. |
num_predict |
Copy only a positive integer (not a boolean); drop every string or float and every non-positive integer. |
think |
Copy only a boolean; drop every other value. |
keep_alive |
Copy the value unchanged. |
For example:
# before
agent:
provider: ollama
model: qwen3:8b
base_url: http://localhost:11434
ollama:
num_ctx: 8192
# after
agent:
active: default
profiles:
default:
model: ollama/qwen3:8b
endpoint: http://localhost:11434
auth: {method: none}
options:
native_thinking: true
num_ctx: 8192
The native_thinking: true line retains v0.4.1's native /api/chat
transport. See the
v0.5.0 migration notes
for the complete set of removed compatibility inputs.
Python API migration from v0.4.1¶
korvid.agent no longer re-exports symbols. Of its 53 package-level exports
in v0.4.1, 51 still exist in their defining modules. Change package imports
to the module in this complete map:
| v0.5.0 defining module | v0.4.1 names formerly exported by korvid.agent |
|---|---|
korvid.agent.engine |
AgentEngine, AgentTurnRequest |
korvid.agent.events |
AgentError, AgentEvent, TextDelta, ToolCallFinished, ToolCallStarted, TurnComplete, TurnInterrupted |
korvid.agent.evidence |
Evidence, EvidenceLedger |
korvid.agent.interaction |
AgentUiBridge, ClusterFacts, DrillDown, InteractionContext, Navigate, OpenDescribe, OpenLogs, PaneContext, ResourceIdentity, SetFilter, UiAction, UiActionResult |
korvid.agent.model_policy |
CapabilitySource, ModelCapabilities, ModelCatalogEntry, ModelDescriptor, ModelRouter, ModelRoutingError, ModelTier, PolicyEnvironment, ResolvedAgentPolicy |
korvid.agent.model_profiles |
DeviceLoginPrompt |
korvid.agent.native_engine |
NativeAgentEngine |
korvid.agent.outbound |
OutboundPolicy, OutboundSnapshot |
korvid.agent.prompt_harness |
ComposedPrompt, PromptCompositionError, PromptHarness, PromptInputs, StaticPromptTooLargeError, cluster_context_note |
korvid.agent.provider |
LLMProvider, REQUEST_SENT |
korvid.agent.request_gateway |
PreparedGatewayRequest, RequestGateway |
korvid.agent.session |
AgentSession, DefaultAgentSession, SessionRetargetError |
korvid.agent.tool_harness |
ToolExecution, ToolHarness |
Direct imports alone are not enough for the contracts that changed shape. The
prompt constructor migration is
PromptHarness(packs=..., provider_overlays=..., model_overlays=...) →
PromptHarness(tier_prompt=..., extra_layers=...); the table gives its
semantics and every other required adaptation. Terminal event constructors
remain source-compatible because their new argument is
diagnostics: TurnDiagnostics | None = None.
| v0.4.1 contract | v0.5.0 migration |
|---|---|
UnknownPromptPackError, UnknownPromptOverlayError |
No replacement. Prompt-pack and overlay registries were removed, so the harness no longer performs those lookups or raises these exceptions. StaticPromptTooLargeError remains. |
PromptHarness(packs=..., provider_overlays=..., model_overlays=...) |
→ PromptHarness(tier_prompt=..., extra_layers=...). Eval code passes one selected tier-prompt replacement and an ordered sequence of explicit extra layers. Production provider/model prompt overrides were removed. |
ModelCatalogEntry.prompt_overlay_ids |
Removed with model overlays; construct entries without it. |
ResolvedAgentPolicy.prompt_pack_id and .prompt_overlay_ids |
Removed. tier selects the shipped prompt and behavior from korvid.agent.tiers.low or .high; eval-only additions use the PromptHarness constructor's extra_layers argument. |
AgentEvent |
The union now also contains AgentPhaseChanged from korvid.agent.events; exhaustive consumers must accept it. |
AgentError, TurnComplete, and TurnInterrupted |
Each adds optional diagnostics: TurnDiagnostics \| None = None. Existing constructors remain valid; consumers may read snapshots from korvid.agent.diagnostics. |
AgentTurnRequest and DefaultAgentSession |
AgentTurnRequest.diagnostics is optional, and DefaultAgentSession(..., diagnostics_factory=None) enables per-turn recording when a TurnDiagnosticsFactory is supplied. Both defaults preserve callers that do not record diagnostics. |
korvid.agent.model_profiles.SpecialFlowRegistry |
korvid.agent.model_profiles.SpecialFlowRegistry moved to korvid.providers.special_flows.SpecialFlowRegistry; use the current registry and flow contract rather than relying on the old two-method implementation. |
ProviderPlugin, ProviderPluginMetadata, ProviderPluginConfig, and PROVIDER_PLUGIN_API_VERSION from korvid.agent.provider_plugin |
The module and construction API were removed without a shim. Publish a SpecialFlow declaration, following Migrating the removed construction API. |
GitHub Copilot
Copilot support uses an unofficial internal API that may change or break without notice, and requires an active GitHub Copilot subscription.
Entra ID ships in its own extra; a tool-managed install reinstalls the complete set:
In a source checkout, uv sync --extra entra does the same job.
A non-standard transport registers a SpecialFlow on the korvid.provider
entry point; custom authentication can use a korvid.credential chain instead.
Provider plugins describes both extension points.
Model search¶
The wizard's model step is a fuzzy search over a two-layer catalog.
Bundled (primary). LiteLLM ships a model_prices_and_context_window.json
table inside its wheel, read at startup — no network call, no internet
required — so 2,000+ models are discoverable offline.
models.dev (optional enrichment). korvid may fetch one JSON document from
https://models.dev/api.json for context lengths, quantization and
environment-variable hints. It is never fetched at startup, on mount, on a
keystroke, or during routing: only Ctrl+R on the search
screen contacts it, forcing an ETag-conditional revalidation and reporting
the outcome inline (Model metadata updated., Model metadata already up to
date., Model metadata unavailable — keeping what korvid already had.).
Otherwise korvid serves its 0600 cache unchanged between operator-requested refreshes.
Enrichment is on by default; agent.model_search.models_dev: false disables it
permanently, and only true and false parse — anything else warns at startup
and is treated as false. The
air-gapped guide has the cache paths and
what the refresh key answers once it is off; the
threat model has the request bounds. Routing never
reads models.dev.
Model tiers and routing¶
korvid resolves one model tier per session, and the agent's budgets and armed tool surface follow from it.
| low | high | |
|---|---|---|
| iterations per turn | 6 | 15 |
| retained history | 24,000 chars (hard bound) | 120,000 chars |
| per tool result | 3,000 chars | the executor's 8,000-char cap |
| tool calls per response | 1 (extras discarded) | parallel, if confirmed |
| screen tools armed | open_logs, open_describe |
all five |
The low tier exists because small local models (3B–14B) handle a frontier tool surface poorly; it ships shorter tool wording and a tighter operating pack, and its history budget is a hard bound — a turn that would push a request past it ends early instead of sending it. Every write tool the environment arms passes the approval gate at both tiers.
Routing precedence: agent.model_tier, then what the provider reports, then
korvid's shipped catalog, then low as the safe fallback. A model that cannot
call tools is refused at startup. The panel header shows the route as
tier (source) — low (catalog), high (user).
agent.model_tier and agent.rules sit beside agent.active, not inside a
profile. Rules are composed after korvid's immutable safety contract: a rule
can add caution but never widen what the agent may do, because approvals,
read-only enforcement and the audit log live in code, not wording.
The eval harness is development-only and excluded from wheels and sdists;
run it from a source checkout after uv sync --frozen --dev --all-extras. The
evaluation methodology, the
scenario catalog and the
model scoreboard cover how a model or a prompt change
is measured.
What the recording demonstrates¶
The capture above is a deterministic synthetic-cluster walkthrough, and the
turn in it is real: the real AgentPanel submits the prompt, the shipped
DefaultAgentSession and NativeAgentEngine dispatch diagnose_pod then
get_logs through the real ToolExecutor, and the real EvidenceLedger mints
[E1] and [E2] and validates the answer's markers against them.
Only the model's side is fixed: the provider is deterministic and offline, and every byte the tools read comes from a synthetic fixture. The clip says nothing about a live model, a live cluster, or answer quality — the read, write, masking and audit guarantees above hold on every real turn regardless.