Adding a new sample#
Read Build your application first to build the application. Read this when
preparing it as a repository sample. For a working sample reference, refer to
agent-samples/simple-vlm-example/. Hard rules and the checklist live in
AGENTS.md; this file holds the boilerplate templates.
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 shared endpoints; the shared model-server
profiles use it to select managed services. 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
shared with the stdlib-only orchestrator 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 shared 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 (used by simple-vlm-example):
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"
requires-python = ">=3.11,<3.13"
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.13"
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 sync && uv 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. 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")
await self._ep.wait_until_running()
if ready_file:
ready_file.touch()
try:
await self._ep_task
except asyncio.CancelledError:
pass
finally:
self._ep_task = None
def shutdown(self) -> None:
# ← cancel any background tasks here first
if self._ep_task:
self._ep_task.cancel()
self._ep.stop()
self._ep.close()
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()