The watch command is the type-watcher half ofpyrpc dev with the server management removed. It reads yourpyrpc.json, generates types once for every configured client, then watches your Python files and regenerates them on every save. No uvicorn, no port probing, no interactive console, just types, kept fresh in the background. If you prefer to own your server process, this is the command you run in terminal two.
Two commands, one regeneration pipeline
pyrpc dev and pyrpc watch are two front doors to the same machinery. The docstrings in cli.py say it plainly: dev is “Start the dev server and keep TypeScript types in sync,” whilewatch is “Watch for Python changes and regenerate TypeScript types. No server started.” The difference is what each command adds around the type pipeline.
Both commands build their regen machinery from the same primitives._find_python_dirs decides which directories to watch._make_regen_callback returns the debounced regen pair,_do_regen and schedule, that turn file events into fresh types. _regenerate_clients does the actual work of writing<client>/__pyrpc.d.ts for every client and configuring eachtsconfig.json. dev adds uvicorn management, server detection, apyrpc.json watcher, and the interactive _DevConsole;watch adds none of those. That contrast is the whole point of the command.
Reading pyrpc.json and applying the --client override
watch takes two inputs: an optional positional module and an optional --client flag. Because both are optional, the command leans on the config file for everything that isn’t passed explicitly:
@app.command()
def watch(
module: str = typer.Argument(None, help="Module to watch (reads pyrpc.json if omitted)"),
client: str = typer.Option(None, "--client", "-c", help="Client project root"),
):
"""Watch for Python changes and regenerate TypeScript types. No server started."""
cwd = os.getcwd()
cfg = _read_config() or {}
module = module or cfg.get("module")
if client:
client_dirs = [client]
else:
client_dirs = _get_clients(cfg)The config resolution reuses the same helpers as every other command._find_config() walks up from the current directory looking forpyrpc.json, and _read_config() parses it, returningNone when the file is missing or unparseable, never crashing. The positionalmodule falls back to cfg.get("module"). For clients, an explicit--client flag wins and becomes a single-element list; otherwise_get_clients(cfg) normalizes the config’s client (single path) or clients (list) fields into one list shape.
Clear errors, not silent defaults
This is where watch is stricter than you might expect. Bothmodule and client_dirs must resolve to something, and if either is empty the command exits with code 1 rather than guessing:
if not module:
console.print("[red]No module specified. Run pyrpc dev first to create pyrpc.json.[/red]")
raise typer.Exit(1)
if not client_dirs:
console.print("[red]No clients configured. Specify --client or configure in pyrpc.json.[/red]")
raise typer.Exit(1)The temptation is to fall back to something sensible, import main, write to the current directory. A silent default is worse than an error: it would generate types against the wrong module, or drop a __pyrpc.d.ts into a directory that isn’t a TypeScript project, and you’d only notice when your editor showed stale or missing types. Because watch has no server and no interactive console, there is no later moment to catch the mistake. Exiting with a clear message at startup is the only honest failure mode.
The initial regeneration
Before it starts watching, watch does a one-shot regeneration._regenerate_clients(module, client_dirs) is called withreload=False, which means _import_module performs a fresh import of the entry module, re-firing all the @rpc decorators intodefault_router, builds the schema via get_registry_schema, and writes one <client>/__pyrpc.d.ts per client. The success line adapts to how many clients are configured:
$ pyrpc watch ✓ types generated (3 procs) → ./frontend watching... (Ctrl+C to stop)
With several clients it reads “types generated (3 procs) for 2 clients.” This initial run matters: a fresh checkout is fully typed before you touch a file, without waiting for the first save.
The watch loop
From there watch enters the same loop dev uses, minus the server. A daemon thread feeds the directories to watchfiles:
_do, schedule = _make_regen_callback(module, client_dirs)
stop = threading.Event()
def _w():
for changes in watch(*_find_python_dirs(cwd), stop_event=stop, yield_on_timeout=True, debounce=200):
if stop.is_set(): break
if any(f.endswith(".py") for _, f in changes): schedule()
t = threading.Thread(target=_w, daemon=True); t.start()
try: t.join()
except KeyboardInterrupt: stop.set(); console.print("\n [dim]stopped[/dim]")Each batch of .py changes calls schedule(), which (re)starts athreading.Timer of _DEBOUNCE_SECONDS = 0.3. When the timer fires,_do_regen runs_regenerate_clients(module, client_dirs, reload=True). Thereload=True flag matters: it routes throughdefault_router.reload_module, which clears the router, re-imports the module so edited procedures register, and restores the previous procedures if the reload fails or exports none. Without it, a plain re-import would return the cached module and regenerate stale types, the exact bug fixed in v0.11.1.
watch has no dev console, no restart command, and no config watcher. It is a long-running process that does exactly one thing, and Ctrl+C stops it cleanly through the shared stop event. That single-responsibility shape is what makes it composable: you can run it next to a manually started uvicorn, a Docker container, or a server running on another machine entirely.
A realistic transcript
# Terminal 1, your server, your flags $ uvicorn main:app --reload --host 0.0.0.0 --port 8080 --log-level debug # Terminal 2: types, and nothing else $ pyrpc watch ✓ types generated (3 procs) → ./frontend watching... (Ctrl+C to stop) # you add a procedure to app/main.py and save 14:22:33 types regenerated (4 procs) for 1 clients # another save a minute later 14:23:41 types regenerated (5 procs) for 1 clients ^C stopped
The regeneration lines carry a timestamp prefix, the callback printstime.strftime(’%H:%M:%S’) before the proc count, so you can see, at a glance, that types refreshed when your code changed.
When to reach for watch
Reach for watch whenever pyrpc dev’s server management is in the way. Three concrete cases: you run uvicorn with custom flags that devdoesn’t expose; your server runs under Docker Compose or a process manager that pyRPC shouldn’t own; or you already have a server running and simply want types , dev would also work there thanks to_server_is_running detection, but watch communicates the intent more clearly in scripts and Makefiles.
dev remains the default because it makes the opinionated choice for you: one command, server plus types, zero coordination. watch is the escape hatch for everyone whose server lifecycle lives outside pyRPC. See the quickstartfor the full workflow.
Read the full changelogfor the complete list of changes.

pyRPC