Adding a new sample#
Overview#
Prepare an existing application for contribution to the repository’s sample catalog. Follow the naming, packaging, launcher, and configuration conventions below so it works with repository tooling.
Build the application first with Build your application; for a current
voice-and-vision sample foundation, refer to
conversation-example.
simple-vlm-example is a deprecated legacy sample; its names remain in
mechanical examples below. Hard rules and the checklist live in AGENTS.md.
Naming conventions#
Choose a kebab-case sample name (for example, simple-vlm-example).
Derive all other names from it mechanically:
Thing |
Convention |
Example |
|---|---|---|
Sample directory |
|
|
Orchestrator module |
|
|
Orchestrator entry point |
|
|
Worker package |
|
|
Worker entry point |
|
|
Agent class |
|
|
Logger name |
|
|
pyproject name (orch) |
|
|
pyproject name (worker) |
|
|
Directory layout#
Workers use a named Python package. This keeps imports unambiguous, includes package resources in built wheels, and gives each worker an explicit module entry point.
agent-samples/<name>/
├── pyproject.toml ← orchestrator project
├── main.py ← orchestrator (declare PROCESSES, call run_stack)
├── yaml/ ← all YAML configs for this sample
│ ├── device_io_hub.yaml
│ ├── <command>.yaml ← one per launchable process
│ ├── models.json ← adapter and endpoint specs; optional deployment metadata
│ └── …
└── worker/
├── pyproject.toml ← worker project
└── <snake_name>_worker/
├── __init__.py ← package marker
├── __main__.py ← entry point: parse arguments and run
└── … ← cohesive workflow, transport, and config modules
yaml/models.json names the logical models the worker needs (llm,
vlm, stt, tts, or any sample-specific name). Each role composes an
adapter and endpoint spec, with optional deployment metadata. Consumer samples
omit deployment and connect to configured endpoints. A sample-local
model-servers/ convenience launcher declares the required local servers
separately and owns small GPU-profile overrides; service defaults live in
services/<service>/. For startup probes, refer to
VoiceAgent and Consumer model readiness.
Worker-only profiles may remain in the legacy flat JSON or YAML shape; a profile
read through the generic launcher deployment loaders requires the
wrapped nested JSON shape including deployment metadata. The worker passes either form to
load_models_config(...) and constructs services with make_llm, make_vlm,
make_stt, or make_tts from xr_ai_models. Schema, preset
table, compatibility formats, and the profile contract are in
xr-ai-models. To reuse a customized local stack, follow
Customizing model servers.
When the worker is small, keep its implementation in the package’s
__main__.py. Split it once argument parsing, lifecycle, configuration, and
workflow composition become distinct responsibilities.
Suggested split (the simple-vlm-example worker is a legacy illustration):
File |
Responsibility |
|---|---|
|
Argument parsing and delegation to the application lifecycle |
|
Dependency construction and native function and runtime composition |
|
Typed configuration loading and path resolution |
|
Package-owned prompt resources |
Sample README and configuration guide#
Give the sample README enough information for a first successful local edit:
describe what the sample does and which processes or shared models it uses;
state
agent-samples/<name>/as the working directory and write every run command relative to it;add a compact Configure section that maps the sample-owned YAML and JSON files to their common settings and shows one representative YAML change;
link to the sample’s canonical guide and generated configuration reference.
Keep the corresponding docs/source/ guide complete. The guide explains how
the launcher selects the files, path and environment precedence, restart
behavior, the responsibility of each configuration file, and the boundary
between a sample endpoint change and a persistent model-server change. Exact
field values and field-level guidance stay beside the checked-in YAML or JSON.
Refer to Sample configuration reference for the rendered reference.
Orchestrator pyproject.toml#
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "<kebab-name>"
version = "0.1.0"
description = "<One-line description of the sample experience>"
requires-python = ">=3.11,<3.15"
dependencies = [
"xr-ai-launcher",
"xr-ai-logging",
]
[tool.uv.sources]
xr-ai-launcher = { path = "../../utils/xr-ai-launcher", editable = true }
xr-ai-logging = { path = "../../utils/xr-ai-logging", editable = true }
[project.scripts]
<snake_name> = "main:run"
[tool.hatch.build.targets.wheel]
only-include = ["main.py"]
Worker pyproject.toml#
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "<kebab-name>-worker"
version = "0.1.0"
requires-python = ">=3.11,<3.15"
dependencies = [
"xr-ai-hub-client",
"xr-ai-logging",
"xr-ai-models",
# add task-specific deps here: numpy, torch, etc.
]
[tool.uv.sources]
xr-ai-hub-client = { path = "../../../agent-sdk/xr-ai-hub", editable = true }
xr-ai-logging = { path = "../../../utils/xr-ai-logging", editable = true }
xr-ai-models = { path = "../../../agent-sdk/xr-ai-models", editable = true }
[project.scripts]
<snake_name>_worker = "<snake_name>_worker.__main__:run"
[tool.hatch.build.targets.wheel]
packages = ["<snake_name>_worker"]
Orchestrator main.py#
Exact boilerplate — do not add logic here:
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""
<Name> agent orchestrator. Runs the process stack for this sample.
How to run (from agent-samples/<name>/):
uv --config-file ../../uv.toml sync
uv --config-file ../../uv.toml run <snake_name>
"""
from pathlib import Path
from xr_ai_launcher import Process, run_stack
from xr_ai_logging import setup_logging
_BASE = Path(__file__).resolve().parent
PROCESSES = [
Process(
"hub",
"../../services/device-io-hub",
"device_io_hub",
config="yaml/device_io_hub.yaml",
),
Process(
"worker",
"worker",
"<snake_name>_worker",
config="yaml/<snake_name>_worker.yaml",
),
]
def run() -> None:
setup_logging("orchestrator", namespace="<kebab-name>")
run_stack(PROCESSES, _BASE)
if __name__ == "__main__":
run()
Raw-IPC worker <snake_name>_worker/__main__.py#
Use this template when the worker talks directly to ProcessorEndpoint. Native
voice workers keep argument parsing in __main__.py and delegate composition
to sibling app.py and config.py modules. For current voice-and-vision
composition, refer to conversation-example;
the following linked worker is the deprecated Simple VLM implementation. Refer to
simple-vlm-example
for that shape. Fill in the sections marked # ← FILL IN.
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""
<Name> agent worker — <one-line description>.
Launched as a subprocess by ``uv run <snake_name>`` (the orchestrator).
Do not run this directly.
Protocol # ← include only if the worker sends or receives data messages
--------
Client → agent (topic "<in.topic>"):
<description>
Agent → client (topic "<out.topic>"):
<description>
Environment # ← include only if env vars are read
-----------
ENV_VAR description (default: value)
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import signal
from pathlib import Path
from xr_ai_hub import ( # ← import only what you use
AudioChunk, DataMessage, FrameSignal, ParticipantEvent, ProcessorEndpoint,
Subscribe, # ← only needed when scoping subscriptions
)
from xr_ai_logging import setup_logging
log = logging.getLogger("<snake_name>")
_HUB_PUB = "ipc:///tmp/xr_hub_pub"
_HUB_PUSH = "ipc:///tmp/xr_hub_in"
class <CamelName>Agent: # ← FILL IN agent logic
def __init__(self) -> None:
self._ep = ProcessorEndpoint(sub_addr=_HUB_PUB, push_addr=_HUB_PUSH)
self._ep_task: asyncio.Task[None] | None = None
self._ep.on_frame(self._on_frame) # ← remove callbacks you don't use
self._ep.on_audio(self._on_audio)
self._ep.on_data(self._on_data)
self._ep.on_participant(self._on_participant)
# ── callbacks ─────────────────────────────────────────────────────────────
async def _on_frame(self, sig: FrameSignal) -> None: ...
async def _on_audio(self, chunk: AudioChunk) -> None: ...
async def _on_data(self, msg: DataMessage) -> None: ...
async def _on_participant(self, event: ParticipantEvent) -> None: ...
# ── lifecycle ─────────────────────────────────────────────────────────────
async def run(self, ready_file: Path | None = None) -> None:
# ← start any application background tasks before the endpoint task
self._ep_task = asyncio.create_task(self._ep.run(), name="hub-endpoint")
try:
await self._ep.wait_until_running()
if ready_file:
ready_file.touch()
await self._ep_task
except asyncio.CancelledError:
if asyncio.current_task().cancelling():
raise
# ignored: shutdown cancelled the child receive loop.
finally:
self._ep_task.cancel()
await asyncio.gather(self._ep_task, return_exceptions=True)
self._ep_task = None
# ← cancel and await application background tasks before closing IPC
self._ep.stop()
self._ep.close()
def shutdown(self) -> None:
# Closing sockets is deferred until the receive loop has unwound.
if self._ep_task:
self._ep_task.cancel()
async def main(ready_file: Path | None = None) -> None:
setup_logging("worker")
agent = <CamelName>Agent()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, agent.shutdown)
log.info("<snake_name> connecting sub=%s push=%s", _HUB_PUB, _HUB_PUSH)
try:
await agent.run(ready_file=ready_file)
finally:
agent.shutdown()
def run() -> None:
p = argparse.ArgumentParser(add_help=False)
p.add_argument("--ready-file", type=Path, default=None)
ns, _ = p.parse_known_args()
asyncio.run(main(ready_file=ns.ready_file))
if __name__ == "__main__":
run()