architecture
What each part does.
And how they connect.
OVAT sits between you and OpenVINO Model Server. OVMS does the inference. OVAT decides which model, which device, which parser and which tools, then runs the loop and the server for you. This page follows docs/ARCHITECTURE.md: a glossary first, then the principles, then each part in turn.
Glossary
The terms this page uses, in plain words.
| Term | Meaning |
|---|---|
| Agent | A program where a language model can ask for your functions to be run, read the results, and then answer. |
| Tool | One of those functions, with a description the model reads. OVAT ships three: search_docs, transcribe and describe_image. |
| Tool call | The model's request to run a tool: a name plus JSON arguments. The model only writes the request; OVAT runs the Python function. |
| Engine | The code that runs the agent loop. OVAT has four: its own native loop, LangChain (react), LlamaIndex and the OpenAI Agents SDK. You pick one with agent.type. |
workflow.yml | The one config file that describes an agent: model, device, tools, engine and prompt. |
| OpenVINO | Intel's toolkit for running AI models fast on Intel CPUs, GPUs and NPUs. |
| OVMS | OpenVINO Model Server. A separate program that loads a model and answers OpenAI-style HTTP requests on /v3. It decodes tool calls, so it is needed for agent runs. |
openvino_genai | OpenVINO's Python library for running a model inside your own process, with no server. OVAT uses it for ovat chat, embeddings, Whisper and vision. |
| Export | A model already converted to OpenVINO's format, for example Qwen3.5-4B-int4-ov. |
| MCP | Model Context Protocol, a standard way to offer tools to an agent. OVAT can use any MCP server as a tool, and its own tools also run as MCP servers. |
| RAG | Retrieval-augmented generation: search your documents first, then give the matching passages to the model so it answers from them. |
| NPU | Neural processing unit, the low-power AI accelerator in Intel Core Ultra chips. |
| Tool parser | The OVMS setting (--tool_parser) that reads a tool call out of the model's text. The right one depends on the model family. |
| KV cache | Memory the server keeps per request so it does not recompute earlier tokens. It grows with the conversation and the answer. |
| Trace | A JSON record of one run: latency and tokens, which tools ran, and whether the run failed. Written by ovat run --trace. |
What OVAT is
OVMS runs the model: it generates text, decodes tool calls, batches requests and targets a device. It does not decide which model, which device, which parser, which tools, or when to start and stop. OVAT turns that gap into a config file, the way kubectl wraps the Kubernetes API rather than replacing it. One workflow.yml in, one wired agent out.
There are two ways to run the model. Through OVMS (model.provider: ovms, the default) is the agent path: ovat run, ovat bench and the terminal UI's /engine ovms. OVMS decodes tool calls, so the agent can use tools; Windows and Linux only. In-process, through openvino_genai (ovat chat, the terminal UI's /chat) needs no server and makes no tool calls: OVAT searches your documents first, then hands the passages to a local model. That path also works on macOS.
| OVAT does | OVAT does not |
|---|---|
| Config to a wired agent | Inference (OVMS or openvino_genai does it) |
| Run the tool-calling loop | Model conversion (optimum-cli) |
| Manage the OVMS process | Serve other people's traffic |
| Suggest a CPU, GPU or NPU device per model | Provide auth (it is a local dev tool) |
| Register tools, local and MCP | Host anything |
Design principles
Four principles shape the code. The reasons behind each one, with the alternatives and measurements that settled them, are in docs/DECISIONS.md.
Optional parts stay optional
The core install runs every command without the terminal UI or any agent framework. tests/test_tui_isolation.py checks that the CLI never imports textual at module level, and a CI job installs the base package alone to check that no framework or TUI package comes with it.
The agent core does not depend on the UI
ovat/agent/ never imports from the CLI or the TUI. Text helpers both need live in a neutral ovat/text.py.
Single source of truth
Each fact has one owner, and everything else derives from it. The list is below.
Errors are written for the reader
A failure becomes something the model or the person can act on. The error contract is below.
Single source of truth: who owns each fact
| Fact | Owner | How the rest derives from it |
|---|---|---|
| Tool contracts | each tool's SCHEMA dict, next to its code | every engine builds its argument models from it through agent/arg_models.py; no engine keeps its own copy |
| Config validity | config/workflow.py | a strict pydantic schema; unknown keys are errors |
| Config loading | main._load_config | the only caller of load_workflow |
| Connection settings | LLMBackend.from_config in providers/backend.py | read by all four engines |
| Colours | ui.PALETTE | used by the CLI and the TUI alike |
| The OVMS version | ovms_installer.OVMS_VERSION | the installer, the docs and the tests read it |
The error contract
Goes back to the model as the tool result, as text starting with Error:, so the model can read it and try again. This covers an unknown tool name, a tool that raises, and arguments that are not valid JSON. The native loop and the OpenAI Agents engine wrap every tool this way; LangChain and LlamaIndex get the tool function unwrapped, so their own handling applies.
Exits non-zero. ovat run exits 1 when the native trace says failed, when a framework engine sets last_failed, when the answer still holds raw tool-call markup, or when the run raises. The answer and any diagnostics are printed first.
With --trace, the trace file is written on every path, success or not. The native trace totals carry four flags: failed, undecoded_tool_call, truncated and empty_answer; a run that raised also gets an error field.
One readable sentence, not a traceback. A missing config file, YAML that does not parse and a schema mismatch each get their own sentence. A server error reads "Error talking to OVMS at ...", and a timeout also names model.request_timeout and how to raise it.
Required and optional parts
What you need depends on what you run. The Python extras come from pyproject.toml.
| Part | Install | What it adds |
|---|---|---|
| Core | pip install ovat | Every command, the native engine, the three built-in tools, the MCP client, RAG with sqlite-vec, and local models through openvino and openvino-genai |
[tui] | pip install "ovat[tui]" | textual and pyfiglet: the full-screen terminal UI |
[langchain] | pip install "ovat[langchain]" | agent.type: react |
[llamaindex] | pip install "ovat[llamaindex]" | agent.type: llamaindex |
[openai-agents] | pip install "ovat[openai-agents]" | agent.type: openai-agents |
[convert] | pip install "ovat[convert]" | optimum-cli, to convert a Hugging Face model such as the bge-small embedder. It pulls in torch, so it is not in the core |
[dev] | pip install -e ".[dev]" | pytest and every framework and TUI package: the test suite |
| Outside the package | How to get it | Needed for | Not needed for |
|---|---|---|---|
| OVMS | ovat setup (Windows and Linux x86-64) | tool-calling runs: ovat run, ovat bench, the TUI's /engine ovms | ovat chat, ovat index with local embeddings, ovat init, ovat doctor |
| An LLM export | ovat serve pulls it, or hf download | anything that generates text | ovat index, ovat doctor |
| An embedding model | optimum-cli from [convert] | RAG: ovat index, search_docs, ovat chat | runs with no rag: section |
| Intel GPU and NPU drivers | Intel's driver downloads | running on GPU or NPU | CPU |
| MCP servers | any MCP stdio server | extra tools through type: mcp_stdio | the built-in tools |
| Intel Unified Telemetry | a separate download from Intel | the power and bandwidth rows in telemetry | every other telemetry row |
| plano gateway | a separate install, see examples/plano/ | per-request OpenTelemetry spans | everything else; it runs outside OVAT |
Config cli/main.py, config/workflow.py, cli/ui.py
Commands and configuration
Every command is a Python function that Typer turns into ovat <command> from its type hints. There are eleven: run, chat, index, init, doctor, setup, serve, models, bench, telemetry and tui. A bare ovat opens the terminal UI.
The config is strict. WorkflowConfig is a pydantic model that forbids unknown keys. A typo such as max_iteration fails at once, by name, instead of being ignored while the default stays in charge.
Only one function loads it. _load_config() in cli/main.py turns three different failures into three plain sentences: the file is missing, the YAML does not parse, or the schema does not match. yaml.safe_load(f) or {} makes an empty or comments-only file a schema error that names the missing section.
Model text is data, not markup. Every value that reaches the console goes through esc(), because the console library reads [...] as a style tag. Model output, exception text and file paths are data.
Names the path it looked for, and notices when a question was typed where the file name belongs.
Gives the line and column where parsing stopped.
Lists each field and what was wrong with it, and explains that unknown keys are rejected on purpose.
Engines agent/*_agent.py, agent/arg_models.py, providers/backend.py
Four engines behind one method
Every engine exposes the same .run(text) -> text. The factory hands back one of them and the CLI never knows which. In C++ terms it is one interface with four implementations.
Two things keep the four consistent. Tool arguments for every engine are derived from each tool's own SCHEMA by agent/arg_models.py, so no engine keeps a second copy. And the server connection (URL, model, timeout, temperature, token cap) is described once, by LLMBackend.from_config in providers/backend.py, which all four read.
Token counts. OVMS returns token usage on every reply, and all four engines record prompt and completion tokens from its usage field, plus tool counts. The native loop records them per turn. The three framework engines record run totals in the same trace shape (agent/usage.py), summed from what each framework kept. A reply with no usage leaves the cell a dash, never 0.
Only LlamaIndex and the OpenAI Agents SDK own an event loop. Those two engines are async and call asyncio.run themselves, which cannot be re-entered, so they check for a running loop and raise a readable error rather than deadlock. LangChain calls invoke() synchronously. In the terminal UI all four work, because the chat screen runs the agent on a worker thread where no loop is running.
agent:
type: native- What
- OVAT's own loop in
agent/loop.py. No extra library. - Run
--native- Install
- Included in
pip install ovat - Tokens
- Prompt and completion tokens from OVMS's
usagefield, per turn, plus tool counts.
agent:
type: react- What
- LangChain's
create_agentwith aChatOpenAImodel pointed at OVMS. - Run
--reactor--langchain- Install
pip install "ovat[langchain]"- Tokens
- Prompt and completion tokens from OVMS's
usagefield, summed per run from LangChain'susage_metadata, plus tool counts.
agent:
type: llamaindex- What
- LlamaIndex's
FunctionAgentwith anOpenAILikemodel. Bothis_chat_modelandis_function_calling_modelmust be set, or it quietly stops calling tools. - Run
--llamaindex- Install
pip install "ovat[llamaindex]"- Tokens
- Prompt and completion tokens from OVMS's
usagefield, summed per run, plus tool counts. Requests are sent unstreamed so every reply carries its usage.
agent:
type: openai-agents- What
- The OpenAI Agents SDK, kept on your machine by three settings: an explicit client on the
/v3URL, the Chat Completions model, and tracing turned off. - Run
--openai-agentsor--openai-sdk- Install
pip install "ovat[openai-agents]"- Tokens
- Prompt and completion tokens from OVMS's
usagefield, summed per run fromraw_responses, plus tool counts.
Agent core agent/loop.py, agent/session.py
The native loop gives the model hands
A model can only write text. The native loop in agent/loop.py is what turns its written request into a real function call. Each round has four beats: ask, read, act, report. Below, one real question from the RAG example goes round twice.
- Ask. Send the whole conversation plus the tool menu. Each menu entry is the tool's own
SCHEMA, sent as is. - Read. The reply carries a call:
search_docswith a query about the memory budget. The loop checks the reply's content, not itsfinish_reasonlabel. - Act. The loop runs the Python function itself. If the arguments are broken JSON, that error goes back to the model as the tool result, so it can fix it.
- Report. The chunks and their source path are added to the history as a tool message. Round two starts.
- Ask again, now with the search result in the conversation.
- No tool calls: that is the answer. Every exit goes through one
_finish, so the trace totals are always filled in. After ten rounds the loop stops and says why.
Why the content and not the label: OVMS documents that on the NPU finish_reason is always "stop", even when a tool call was decoded.
Memory is just a list. A model forgets everything between calls, so agent/session.py keeps the conversation and resends it each time. It is thread-safe because the terminal UI streams an answer on one thread while you can save or load on another; it copies the list under a lock and writes the file outside it.
Small models get some slack. Arguments wrapped in a Markdown code fence are unwrapped before parsing, a misspelt tool name is matched to the closest real one and the trace records the tool that ran, and a slightly wrong file path is swapped for the closest real file, always with a note in front of the result saying so.
Providers providers/
Models, embeddings and search: sockets and plugs
providers/base.py defines four abstract classes: the sockets. Each concrete class beside it is a plug. The config names a plug as a string, so swapping a backend is a YAML edit. The agent loop and all four engines only ever see the interface.
LLMProvider
ovmsOpenAI SDK to OVMS/v3. Can call tools.genaiopenvino_genai in your process. No tools, no server.
EmbeddingsProvider
genaiLocal text embedding pipeline, bge-small.ovmsOVMS/v3/embeddings.
RetrieverProvider
sqlite-vecVectors and text on disk; survives restarts.memorynumpy in memory; leaves nothing behind.
VLMProvider
genaiOpenVINO VLMPipeline, used bydescribe_image.
Document search, end to end
RAG means retrieval-augmented generation: find the right passages first, then let the model answer from them. OVAT does it in two phases. ovat index runs once; search_docs runs whenever the model asks, and ovat chat always searches first.
- Read the folder.
ovat indexwalks every .txt and .md file under it. - Slice into chunks. 512 characters each, with 64 shared with the next, so a sentence on a boundary is whole in at least one chunk.
- Store meaning as numbers. bge-small turns each chunk into 384 numbers. Re-indexing a file replaces its old chunks, so running it twice adds nothing.
- Later, the model asks. It calls
search_docs, and the question is embedded with the same model. - Find the closest chunks. Close in meaning, not in spelling. sqlite-vec returns the top k.
- Return text and its source path. OVAT prints the paths itself as
sources:rather than hoping the model cites them.
The index remembers when it read each file. ovat index writes <db>.sources.json beside the database, and ovat run and ovat chat name any file that changed or vanished since. The nearest-neighbour query uses sqlite-vec's own k = ? form, which works on every SQLite version OVAT supports, and CI runs on Ubuntu 22.04 to cover the oldest one.
Tools tools/
Three built-in tools, and any MCP server
Each built-in tool follows one pattern: a plain *_impl() function you can unit test, a SCHEMA dictionary beside it that is the tool's contract, and a FastMCP wrapper so the same file also runs as a standalone MCP server.
MCP is a standard way for a program to offer tools to any agent. OVAT is an MCP client too: declare type: mcp_stdio, and the factory starts the server, asks it which tools it offers, and hands them to the loop in the same shape as the built-ins. The loop cannot tell them apart.
| Tool | In | Out | Runs on |
|---|---|---|---|
search_docs | a question, top_k (default 5) | matching chunks with their source path | your index, via the embedder |
transcribe | a path to an audio file | the transcript | Whisper, CPU by default |
describe_image | a path to an image | a description | a vision model; Qwen3.5 is one export for text and vision |
The MCP client is sync over async. The mcp SDK is asynchronous, the loop is not. Each server gets one background thread with one event loop and one manager coroutine that connects, serves and unwinds. Closing sets an event and lets that coroutine unwind itself, because the SDK's cancel scopes must be entered and left by the same task.
A server gets a short environment. An MCP server starts with only HOME, LOGNAME, PATH, SHELL, TERM and USER, so a third-party server cannot read every secret in your shell. tools[].env passes more, and ${VAR} in a value is read from your shell.
A separate process needs the config, not the object. An MCP server is a new Python process, and objects do not cross that line, so an MCP-served search_docs takes --config and builds its own retriever from the workflow.
Serving core/ovms_installer.py, core/model_server.py, core/ovms_locator.py
Getting OVMS installed, started and stopped
ovat setup picks the right OVMS archive for your OS (and, on Linux, your distribution), checks its SHA-256, and unpacks it into ~/.ovat/ovms. Nothing is added to PATH. It always takes the python_on build: the python_off build cannot do tool calling. The pinned version, OVMS 2026.4.1, lives in one place, ovms_installer.OVMS_VERSION, and ovat setup and ovat doctor both say when the installed binary differs from it.
It is a command rather than part of pip install because the archive is 126 to 185 MB, Linux needs three different builds, wheels have no post-install step, and macOS has no build at all.
Where OVAT looks for the server, in order
model.ovms_binaryin your YAMLOVAT_OVMSenvironment variablePATHthe classic place./ovms~/.ovat/ovms~/ovms_windows~/ovmsC:\ovmsknown folders
A stall budget, not a deadline
The first ovat serve downloads the model, about 185 s for Qwen3.5-4B and longer on a slow link. The clock resets whenever the log or the model folder grows, and gives up only after 300 s of no progress at all.
Logs go to a file
A pipe nobody reads fills its buffer, around 64 KB, and the server freezes. OVMS writes to ovms.log instead.
Stop checks identity
ovat serve --stop reads ovms.pid and confirms the process really is OVMS before signalling it, because a stale PID can belong to another program.
Devices core/device_manager.py
A suggested device for each model
core/device_manager.py asks OpenVINO which devices exist, then looks the answer up in a routing table. The result is a default suggestion: ovat doctor shows it, ovat init writes the suggested LLM device into a new workflow.yml, and the transcribe and describe_image tools use it when their own device: is not set. Any model can run on any device OpenVINO supports for it. In workflow.yml, model.device defaults to CPU, and whatever you set there wins. Pick a machine to see the suggestions.
A Mac or a plain laptop. OpenVINO reports ['CPU'], so every suggestion is the CPU. Low-bit weights keep a model in memory.
A GPU is found, so the suggestion for the LLM is the GPU: it handles changing input sizes, the KV cache and tool calling on any export. Embeddings and Whisper are suggested for the CPU.
A full Intel AI PC. Embeddings are suggested for the NPU: small, fixed-shape and low power. The LLM suggestion stays the GPU, and Whisper is small enough for the CPU.
These are suggestions. The LLM ran a tool-calling agent on the NPU too, from a channel-wise INT4 export (-int4-cw-ov). See the NPU results.
No device runs tools. The device runs the model. The agent loop reads the tool call out of the generated text and runs the Python function itself, so tool calling depends on the model and the parser, not the hardware.
Where the models come from. OpenVINO publishes models already converted and optimised per device on Hugging Face, in the OpenVINO organisation. Some are tuned for one device, such as the LLMs optimised for NPU collection, and many run well on more than one. For serving an LLM on the NPU in OVMS, the export OVAT has measured working is the channel-wise INT4 one, the -int4-cw-ov models.
| Serving an LLM on the NPU | Why it matters |
|---|---|
A channel-wise symmetric INT4 export, the -int4-cw-ov family | Measured working with tool calling; the stock -int4-ov export tried here did not compile on the NPU. |
A prompt length cap, set with --max_prompt_len | OVMS defaults to 1024 tokens on the NPU; OVAT raises it to 4096 because an agent prompt grows every round. |
| No batching, no beam search | Requests are handled one at a time. |
| KV cache settings are ignored | The NPU runs a fixed-shape pipeline instead, with a hard total-length cap. |
Telemetry telemetry/, agent/loop.py
Telemetry: sources, a collector, sinks
OVAT collects machine and process metrics (CPU per core, RAM, threads, its own resident memory), NPU busy %, KV cache usage from ovms.log, power and bandwidth from Intel Unified Telemetry when it is installed, and the per-run agent trace: latency, tokens, tool calls, failure flags and peak memory.
Where a number comes from (a source) and where it goes (a sink) are separate contracts, so any source feeds any sink. The collector polls every source on its own thread, once a second by default, so a stuck sensor cannot slow the agent.
There is no OTLP exporter. OVAT's telemetry goes to a JSON Lines file or the live terminal UI page. Per-request OpenTelemetry spans come only from the optional plano gateway, which runs outside OVAT; OVAT has no OpenTelemetry dependency of its own. plano measures the transport (latency, time to first token, HTTP status); OVAT measures what the agent did.
Absent is not zero
No token count from the server is null, shown as a dash. A 0 would read as "used no tokens", and a benchmark built on it would be wrong.
An unavailable source says why
On macOS the Intel row reads "Intel Unified Telemetry does not run on macOS" instead of showing zeros. A missing sensor and an idle one look the same in a graph.
Peak memory is sampled during the run
A reading taken afterwards misses the peak, because Python has already freed the big allocations. With OVMS serving, the model's memory lives in the server process, not in OVAT.
ovat bench runs each engine in a fresh process, because memory is a whole-process number: engines sharing a process would inherit each other's allocations. The measurement is on the results page.
Orchestration not in the code
Multi-agent orchestration (A2A): not implemented
Agent-to-agent (A2A) orchestration was a stretch goal in the project proposal, and the core toolkit works without it. It is out of scope, and there is no A2A code in OVAT.
Cross-cutting failure detection
Four ways a tool call goes wrong, each with a name
All four end the same way: the agent answers fluently and has called nothing. That is the hardest kind of broken to notice, so the loop detects each one and sets a flag in the trace. ovat bench scores on those flags, not on the answer text, and ovat run checks the final answer for markup on every engine.
The reply was cut at max_tokens (4096 by default), so any tool-call markup is a fragment. Detected from finish_reason: "length", so it is never blamed on the parser.
Raw <tool_call> markup reached the answer: no parser matched, or the model wrote the call malformed. Usually the wrong tool_parser.
Nothing is left once the reasoning is removed. The parser swallowed the reply.
Text remains, with no markup in it. The run exits 0. Every other case exits non-zero, writes its trace, and cleans up.
The broken markup is deliberately not repaired into a tool call. Guessing at broken output trades a loud failure for a silent wrong answer.
Appendix mapping to the proposal
Mapping to the GSoC proposal's layers
The project proposal described OVAT as nine numbered layers. This page is organised by what each part does instead; this list maps one to the other.
- 1CLI and configurationCommands and configurationbuilt
- 2Framework integrationAgent enginesbuilt
- 3Agent coreThe native loopbuilt
- 4Provider abstractionProviders and RAGbuilt
- 5Tools and MCPTools and MCPbuilt
- 6Orchestration (A2A)Multi-agent (A2A)not built
- 7TelemetryTelemetrybuilt
- 8Deployment and servingServing the modelbuilt
- 9OpenVINO runtime and hardwareDevicesbuilt