How to set up, run, test and extend nilo-server. Deployment (Docker, Compose, images) is in
deployment.md; a first-run walkthrough is in
getting-started.md.
Nilo/ repository root
├── Makefile every command in this page has a target here
├── Dockerfile-server runtime image (FROM the base image)
├── Dockerfile-server-base base image: python:3.12-slim + system deps
├── README.md product overview and status
├── LICENSE
├── docs/ this documentation tree
├── scripts/
│ ├── check_docs.py validates docs links, repo paths, env vars and branding (run in CI)
│ ├── smoke_check.py probes a running server on every protocol route (stdlib + websockets)
│ └── sync-upstream.sh advances the vendored `upstream` branch
├── .github/
│ ├── workflows/ test.yml, docker-image.yml, build-base-image.yml
│ ├── PULL_REQUEST_TEMPLATE.md
│ ├── ISSUE_TEMPLATE/ bug_report, feature_request, documentation, config
│ └── dependabot.yml weekly pip updates for main/nilo-server, monthly Actions
└── main/nilo-server/ the Python server
Inside main/nilo-server/:
| Path | Owner | What it holds |
|---|---|---|
app.py |
Nilo | Entry point: loads config, starts the WebSocket server and the HTTP server as two asyncio tasks, logs the enabled routes |
config/ |
inherited, edited | config_loader.py (YAML layering, NILO_* overrides; DEPRECATED_KEYS is empty — there are no config aliases left), settings.py, logger.py, opus_loader.py, placeholders.py, assets/ (prompt audio) |
core/ |
inherited | connection.py, websocket_server.py, http_server.py, api/ (OTA, vision), handle/ (text and audio message handling), providers/ (ASR/TTS/LLM/VLLM/VAD/memory/intent/tools), utils/ |
plugins/ |
inherited | Unified plugin system: BasePlugin interceptors and @register_function tools (plugins/README.md) |
plugins_func/ |
inherited | Shipped tool functions (get_time, get_weather, play_music, …) plus back-compat re-exports of plugins.register |
robot/ |
Nilo | Nilo-owned package: protocol/ (route registry), state/ (domain models, freshness, state store, the action vocabulary), events/ (typed events, async bounded bus), devices/ (robot registry, tool and capability registries, MCP discovery), safety/ (limits, the deterministic policy, the emergency-stop latch, the watchdog), actions/ (the ten semantic actions, the queue and resource ledger, the registry, the executor), behavior/ (the deterministic autonomy engine: tuning, scheduler, sixteen behaviours, the explain CLI), personality/ (traits, the internal control variables and their store), animation/ (YAML animations and the engine that plays them), vision/ (the snapshot perception pipeline, providers, tracker and face registry), memory/ (working, episodic, semantic and person memory over SQLite), api/ (the admin API), simulator/ (a simulated robot on a real socket), runtime.py (the control plane), session.py (the seam onto a live session), telemetry.py and logging.py. See robot-domain.md, robot-actions.md and behavior-system.md, robot-personality.md, robot-animation.md, robot-vision.md, robot-memory.md |
tests/ |
Nilo | pytest suite; conftest.py sets NILO_CONFIG; fixtures/test_config.yaml is an empty override |
performance_tester/ |
inherited | Standalone latency benchmarks for ASR, TTS, LLM and vision-LLM providers |
models/ |
vendored | Silero VAD source and SenseVoiceSmall configuration; weights are not tracked (model.pt is gitignored) |
libs/ |
vendored | Prebuilt Opus shared libraries (win/x64, mac/x64, mac/arm64) |
config.yaml |
Nilo | Shipped defaults; user overrides go in data/.config.yaml (gitignored) |
.ruff.toml, mypy.ini, pyproject.toml |
see below | Lint, type-check and pytest configuration |
data/ |
runtime | User config, firmware uploads under data/bin/, runtime state. Gitignored in full |
Which parts are inherited from the upstream project and how they are synced: upstream.md. What was renamed and deleted during the migration: migration.md.
Requirements: Python 3.12, ffmpeg on the PATH (app.py calls check_ffmpeg_installed()
at startup), and an Opus shared library — bundled in libs/ for Windows and macOS, installed
as libopus0 on Linux.
cd main/nilo-server
python3.12 -m venv .venv && . .venv/bin/activateThere are two dependency tiers, and which one you need depends on what you are doing:
| File | Size | Installs | Enough for |
|---|---|---|---|
requirements-dev.txt |
10 pins | pytest, pytest-asyncio, freezegun, ruff, mypy, plus PyYAML, httpx, cnlunar, pydantic, loguru | make lint, make typecheck, and most of make test |
requirements.txt |
39 pins | The full runtime: torch/torchaudio 2.2.2, funasr, modelscope, silero_vad, opuslib_next, aiohttp, websockets, openai, mcp, … | Running the server, and the tests that touch aiohttp/websockets/numpy |
pip install -r requirements-dev.txt # fast slice: lint, types, most tests
pip install -r requirements.txt -r requirements-dev.txt # everythingThe dev slice exists because installing torch and funasr takes minutes and hundreds of megabytes; the test suite is deliberately written so that the useful part of it runs without them (see Tests).
macOS: requirements.txt pins vosk==0.3.45; sys_platform != "darwin" because vosk
publishes no macOS wheels. pip skips it automatically — no editing required; the vosk ASR
provider is simply unavailable on macOS.
Version pins: torch/torchaudio are pinned together because funasr requires that pair,
numpy is left unpinned so the resolver can solve, and websockets is held at 14.2 because
cozepy requires <15. Dependabot proposes weekly pip updates against main/nilo-server.
The server needs a user override file. In normal use that is data/.config.yaml; for a
throwaway run, point NILO_CONFIG at any YAML file instead:
cd main/nilo-server
echo '{}' > /tmp/nilo-dev.yaml
NILO_CONFIG=/tmp/nilo-dev.yaml python app.pyIf neither exists, startup fails fast with a FileNotFoundError naming the path
(config/settings.py:check_config_file, reached through config/logger.py:setup_logging).
An empty file is valid — every key then comes from config.yaml.
Run from main/nilo-server, not from the repository root. The provider factories test for a
provider module with a relative path (os.path.join('core', 'providers', 'llm', …) in
core/utils/llm.py), so a different working directory makes every provider look unsupported.
make run does the cd for you.
Five environment variables are recognised; they override both config files (configuration.md):
| Variable | Overrides |
|---|---|
NILO_CONFIG |
Path of the user override file (default data/.config.yaml) |
NILO_SERVER_HOST |
server.ip |
NILO_SERVER_PORT |
server.port (WebSocket, default 8000) |
NILO_HTTP_PORT |
server.http_port (HTTP/OTA, default 8003) |
NILO_LOG_LEVEL |
log.log_level |
Against a running server, scripts/smoke_check.py checks the OTA endpoint, the WebSocket
handshake and the hello exchange on every enabled protocol route, and separately checks that
the routes this server retired answer 404. It also reports how an unknown WebSocket path is
handled — rejected unless protocols.strict is false:
make smoke # HOST=127.0.0.1 WS_PORT=8000 HTTP_PORT=8003 by default
python scripts/smoke_check.py --host 127.0.0.1 --ws-port 8000 --http-port 8003The Makefile at the repository root is the short form of every command below. PY defaults
to python; override it to use a specific interpreter (make test PY=.venv/bin/python).
| Target | Runs |
|---|---|
make test |
cd main/nilo-server && python -m pytest -q |
make lint |
cd main/nilo-server && ruff check . |
make typecheck |
cd main/nilo-server && mypy |
make check-docs |
python scripts/check_docs.py — links, repo paths, NILO_* names, branding |
make run |
cd main/nilo-server && python app.py |
make compose-validate |
cd main/nilo-server && docker compose -f docker-compose.yml config --quiet |
make docker-build |
Builds ghcr.io/aa-box/nilo-server:base and :latest |
make smoke |
python scripts/smoke_check.py against a running server |
make help |
Lists the above |
Ruff, configured in main/nilo-server/.ruff.toml — a separate file rather than a
[tool.ruff] block, because pyproject.toml is vendored from upstream and every line added
there is a line that can conflict on the next sync.
make lint # or: cd main/nilo-server && ruff check .The rule set is deliberately narrow. It is a repo-wide floor: bug-only rules that are already green on the inherited tree, so CI can gate on them today without reformatting ~30k lines of inherited Python.
| Selected | Catches |
|---|---|
E9 |
Syntax and IO errors |
F63 |
Comparison and assertion bugs (is with a literal, assert on a tuple) |
F7 |
Misplaced statements (break outside a loop, return outside a function) |
F82 |
Undefined names, undefined locals |
F811 |
Redefinition of an unused name |
PLE |
Pylint errors (bad string format, invalid __all__) |
B002, B011, B018 |
++n no-ops, assert False, useless expressions |
Settings: target-version = "py312", line-length = 120; .venv, data, tmp, models
and libs are excluded. No style or import-order rules are enabled, so Ruff will not reformat
inherited code out from under an upstream merge.
Planned: a second tier, robot/.ruff.toml, extending this file with the full style and
typing rule set so new Nilo code is held to a higher bar than the vendored tree. It does not
exist yet; see robot-roadmap.md.
pytest, configured in pyproject.toml (asyncio_mode = "auto", testpaths = ["tests"],
addopts = "-ra").
make test # or: cd main/nilo-server && python -m pytest -qtests/conftest.py does two things before any test imports a project module:
- Inserts the
main/nilo-serverdirectory at the front ofsys.path, so the implicit top-level imports the inherited code uses (from core.utils.textUtils import …) resolve. - Sets
NILO_CONFIG(viasetdefault) totests/fixtures/test_config.yaml— a committed, empty ({}) override file. Tests therefore never read or write yourdata/.config.yaml, and the config and logging machinery works on a machine that has never been configured.
With the full dependency set the suite is 232 tests and runs in about two seconds. Coverage
today: the config loader and NILO_* overrides, the robot domain layer (models, state store,
event bus, registry, MCP discovery, the session seam — robot-domain.md),
robot/protocol, the HTTP routes and the WebSocket path gate, the plugin registry and loader,
text/dialogue/time utilities, the Compose file, and an import test that walks every
first-party module.
Three modules call pytest.importorskip at module scope, so the whole file is skipped when
the heavy runtime packages are missing:
| Test module | Requires |
|---|---|
tests/core/test_http_routes.py |
aiohttp, opuslib_next, numpy, pydub |
tests/core/test_ws_path_gate.py |
websockets, opuslib_next, numpy |
tests/plugins_func/test_loadplugins.py |
opuslib_next, numpy |
tests/test_imports.py is parametrized over every module in config, core, plugins,
plugins_func and robot and skips the individual modules whose optional provider
dependency is not installed, rather than failing.
CI runs the suite twice for exactly this reason — once with requirements.txt +
requirements-dev.txt, once with requirements-dev.txt alone — so a change that silently
makes the fast slice unrunnable is caught.
mypy, configured in main/nilo-server/mypy.ini (again a separate file, same reason as
.ruff.toml).
make typecheck # or: cd main/nilo-server && mypyfiles = robot, so the bare mypy command checks only the Nilo-owned package — mypy . is
not a goal and would report thousands of errors on untyped inherited code.
| Scope | Settings |
|---|---|
robot.* |
Strict: disallow_untyped_defs, disallow_incomplete_defs, disallow_untyped_calls, disallow_any_generics, no_implicit_optional, warn_return_any, strict_equality |
core.*, config.*, plugins.*, plugins_func.* |
ignore_errors = True, follow_imports = skip — robot code may import them without inheriting their errors |
opuslib_next, funasr, silero_vad, cnlunar, loguru, ormsgpack, aioconsole |
ignore_missing_imports = True (no stubs published) |
New code under robot/ is written fully annotated from the start. Both ruff check . and
mypy are green on the current tree.
.github/workflows/test.yml runs on pushes to main/develop and on every pull request,
with working-directory: main/nilo-server:
| Job | What it does |
|---|---|
| Lint and type-check | pip install -r requirements-dev.txt, then ruff check ., mypy, and python scripts/check_docs.py from the repository root |
| Python 3.12, full dependencies | Installs both requirements files, runs pytest -q |
| Python 3.12, dev dependencies only | Installs requirements-dev.txt alone, runs pytest -q |
| Docker Compose validates | docker compose -f docker-compose.yml config --quiet |
Two more workflows build images: docker-image.yml and build-base-image.yml
(see deployment.md).
The rule, stated in main/nilo-server/CLAUDE.md and expanded
in robot-architecture.md:
New robot functionality goes under
robot/; attach tocore/through the smallest possible hook. Every line changed incore/is a line that conflicts on the next upstream port.
robot/ is the only Nilo-owned Python package. It is strictly typed, product-neutral in its
vocabulary (see branding.md), and today contains the protocol registry and the
robot domain layer — models, state store, event bus, robot registry and MCP capability
discovery (robot-domain.md). Actions, behaviour, personality, robot
memory, the simulator and the safety policy are Planned, not implemented.
The inherited server offers four extension points that need no edit to core/:
flowchart LR
DEV["device / firmware"] -->|WebSocket| CONN["core/connection.py<br/>ConnectionHandler"]
CONN --> REG["① message_registry.register_handler()<br/>core/handle/textHandle.py"]
CONN --> MCP["② call_mcp_tool()<br/>core/providers/tools/device_mcp/mcp_handler.py"]
LLM["LLM tool call"] --> FUNC["③ @register_function(..., ToolType.IOT_CTL)<br/>plugins/register.py"]
CONN --> SCAN["④ scan_plugins()<br/>plugins/__init__.py, called from core/connection.py"]
REG --> ROBOT["robot/ (Nilo-owned)"]
MCP --> ROBOT
FUNC --> ROBOT
SCAN --> ROBOT
| Seam | Code | Use it for |
|---|---|---|
| ① Inbound message types | core/handle/textHandle.py:message_registry → core/handle/textMessageHandlerRegistry.py:TextMessageHandlerRegistry.register_handler |
New JSON type values on the device WebSocket. The registry keys on handler.message_type.value with no isinstance check, so a robot-owned enum works. Do not reuse the names in robot/protocol/nilo.py:RESERVED_MESSAGE_TYPES |
| ② Outbound device calls | core/providers/tools/device_mcp/mcp_handler.py:call_mcp_tool |
Invoking a tool the device published over MCP. Pass an explicit short timeout; the default is 30 s |
| ③ LLM-visible tools | plugins/register.py:register_function with ToolType.IOT_CTL |
core/providers/tools/server_plugins/plugin_executor.py:ServerPluginExecutor.get_tools exposes a function to the LLM if its name is in necessary_functions, in config["Intent"][selected]["functions"], or if its type code is IOT_CTL — the last is the only branch that needs no config edit |
| ④ Bootstrap | plugins/__init__.py:scan_plugins, called at import of core/connection.py |
A new plugins/<name>/__init__.py directory is never a merge conflict. It runs at import time, before the event loop exists, so defer async work |
Two traps worth repeating: ToolType exists twice with different members — import it from
plugins.register, not from core.providers.tools.base — and plugin import failures are
swallowed and printed by plugins/__init__.py:_import_module_safe, so a new plugin should assert its own
registration rather than trust the log.
When a core/ edit really is unavoidable, keep it to a few lines, wrap it so a failure cannot
break a voice session, and list it under "Inherited-code changes" in the pull request.
Full guide: plugins/README.md. In short, one
directory per plugin under plugins/, each with an __init__.py, which may contain either or
both of:
- An interceptor — a
plugins/base.py:BasePluginsubclass whoseasync def pre_process_text(self, conn, text)returns(text, PluginAction).RELEASEpasses the text on,INTERCEPTspeaks the returned text and skips the LLM,CLOSEdoes that and then closes the connection. Its__init__must accept aloggerkeyword —plugins/__init__.py:register_plugins_to_conninstantiates it withlogger=conn.logger. - Tool functions — decorated with
@register_function(name, openai_function_schema, ToolType.…)fromplugins/register.py, returning anActionResponse(Action.…, result, response).ToolTypeis one ofNONE,WAIT,CHANGE_SYS_PROMPT,SYSTEM_CTL,IOT_CTL,MCP_CLIENT;Actionis one ofERROR,NOTFOUND,NONE,RESPONSE,REQLLM,RECORD.
scan_plugins() treats every subdirectory with an __init__.py as a plugin, skipping names
that begin with _ or . and the functions/ directory. Every discovered plugin is enabled
at priority 100, because the server constructs PluginManager() without a config path. The
tools that ship with the server live in plugins_func/functions/ and are loaded separately by
core/connection.py via auto_import_modules("plugins_func.functions"). How tools reach the
LLM alongside device IoT, device MCP and remote MCP tools: mcp.md.
Providers are resolved by filename. Drop a module into the right directory and add a config
block whose type matches that filename — no registry to edit. The factory is
create_instance in the matching core/utils/<kind>.py, and it looks for the module relative
to the working directory, so it only resolves when the process was started from
main/nilo-server.
| Kind | Module path | Class the module must define | Config section |
|---|---|---|---|
| ASR | core/providers/asr/<type>.py |
ASRProvider |
ASR |
| TTS | core/providers/tts/<type>.py |
TTSProvider |
TTS |
| VAD | core/providers/vad/<type>.py |
VADProvider |
VAD |
| Vision LLM | core/providers/vllm/<type>.py |
VLLMProvider |
VLLM |
| LLM | core/providers/llm/<type>/<type>.py |
LLMProvider |
LLM |
| Intent | core/providers/intent/<type>/<type>.py |
IntentProvider |
Intent |
| Memory | core/providers/memory/<type>/<type>.py |
MemoryProvider |
Memory |
Each kind has a base.py with the abstract base to subclass. The config block's name is what
selected_module points at; its type field (defaulting to the block name) is what selects
the module. An unknown type raises ValueError: Unsupported <kind> type: … - check the 'type' field of that config block. Provider inventory and per-provider keys:
providers.md and configuration.md.
performance_tester/ holds standalone benchmarks that instantiate the providers your config
selects and measure latency. They are inherited code, and they are not part of the pytest
suite.
| Module | Measures |
|---|---|
performance_tester_llm.py |
LLM first-token and total latency, using module_test.test_sentences and the prompt template |
performance_tester_asr.py |
Batch ASR over the WAV files it finds |
performance_tester_stream_asr.py |
Streaming ASR |
performance_tester_tts.py |
Batch TTS |
performance_tester_stream_tts.py |
Streaming TTS |
performance_tester_vllm.py |
Vision LLM |
Run them as modules from main/nilo-server — they use the same implicit top-level imports as
the rest of the server, so running the file by path fails with ModuleNotFoundError: No module named 'core':
cd main/nilo-server
python -m performance_tester.performance_tester_llmThey need the full requirements.txt, real credentials in your config for the providers you
want measured, and tabulate, which is imported for the report table but is not pinned in
either requirements file — pip install tabulate before the first run.
.github/PULL_REQUEST_TEMPLATE.md asks for a summary, a checklist, an explicit list of
inherited-code changes, and the commands you ran. The checklist:
-
make lintandmake typecheckpass -
make testpasses (full dependencies, or the dev slice with the relevant tests un-skipped) - Devices still connect on
/nilo/v1/, if this touches the protocol or server startup - Docs under
docs/updated for any behaviour, config key, route or command that changed - No new Chinese-language strings, no product branding in domain code (see branding.md)
- Changes to inherited
core/code are minimal and noted below
The last two are the ones that are easy to trip over. English only in code, comments, log
lines and docs; the product name belongs at product boundaries (process name, env vars,
routes, logs) and never in domain class or module names. If you did touch core/, config/
or plugins_func/, list the files and say why a robot/-side hook was not enough.
Branch targets are main and develop; upstream is a vendor branch that must never receive
Nilo commits.
Inherited code is synced by merging the vendored upstream branch, not by cherry-picking:
./scripts/sync-upstream.sh # advance `upstream` to upstream main
./scripts/sync-upstream.sh v0.9.6 # or a specific tag
git merge upstreamThe expected conflict classes and how to resolve each of them — renames, deleted components,
comment-only hunks, config.yaml, requirements.txt — are documented in
upstream.md. Read it before the first merge.
There are none. The inherited Java management console (manager-api), its Vue web UI
(manager-web), the mobile client (manager-mobile) and the browser test client were all
removed during the migration; nilo-server is a single Python process configured by YAML
files. Details and rationale: migration.md. A Nilo management API is
Planned (robot-roadmap.md).