Execution Lifecycle¶
This page documents the complete flow from voice input to command response, including every hook and validation step.
Who Does What¶
Two programs share a voice turn:
- The node (the Python node on a Pi, or a desktop agent) runs your command. It tries
pre_route(), sends its commands' tool schemas tojarvisd, runs the tool callsjarvisdsends back, and handles button taps. jarvisd, the server, transcribes the audio, runs the model turn with the node's tools, and turns the results into the spoken reply. Its command-center module never imports or runs command code.
The node starts every exchange: it sends a request and gets the model's tool calls back in the HTTP response, runs them, and posts the results in its next request.
Full Lifecycle Diagram¶
flowchart TD
A[Voice input on the node] --> T[jarvisd transcribes]
T --> B{"pre_route() on the node"}
B -->|PreRouteResult| F[execute]
B -->|None| C[jarvisd: model turn with the node's tools]
C --> D[Tool call returned to the node]
D --> E[post_process_tool_call]
E --> F
F --> G[secret presence check]
G -->|MissingSecretsError| ERR[Tool error]
G -->|OK| H[param presence check]
H -->|Missing required| ERR2[ValueError: tool error]
H -->|OK| IMG[image params to JarvisImage]
IMG -->|Bad image| M[validation_error Response]
IMG -->|OK| I[validate_call]
I -->|Any error| M
I -->|All pass| K[Apply suggested_value corrections]
K --> L[run]
L --> N[CommandResponse]
%% Borders only: a fill here would keep the theme's label colour, white in dark mode.
classDef start stroke:#a537d6,stroke-width:3px
classDef done stroke:#2f9e57,stroke-width:3px
classDef fail stroke:#d9434d,stroke-width:3px
classDef invalid stroke:#c27a12,stroke-width:3px
class A start
class N done
class ERR,ERR2 fail
class M invalid
Step-by-Step Walkthrough¶
1. Pre-Route Check (on the node)¶
Before it contacts jarvisd with the request, the node calls pre_route() on every command it has, with the transcribed text. The first command that returns a PreRouteResult wins.
def pre_route(self, voice_command: str, *, disabled_pattern_ids=frozenset()) -> PreRouteResult | None:
if "music.pause" in disabled_pattern_ids:
return None
text = voice_command.lower().strip()
if text in ("pause", "pause the music"):
return PreRouteResult(arguments={"action": "pause"})
return None
Most commands don't override pre_route(): they declare fast_path_patterns, and the default pre_route() matches them (see Interface Reference). The user can turn single patterns off in the mobile app; the node passes their ids in disabled_pattern_ids, and an override must honour them.
If a command returns a PreRouteResult:
- No model turn runs at all
- The node calls
execute()with the result'sarguments, andrequest_info.is_pre_routed=True - The reply is
spoken_responseif set, otherwise the response'scontext_data["message"] - This is much faster (no model latency)
- If the command fails, or succeeds without a
messageorspoken_response, the node says nothing and sends the request tojarvisdas usual (so the command may run again there)
If all commands return None:
- The node sends the text to
jarvisd
When to use pre_route:
- Short, unambiguous commands ("pause", "skip", "resume", "volume 50")
- Deterministic patterns that never need model interpretation
- Keep the patterns tight: a false match takes the request away from the model
2. Model Turn (on jarvisd)¶
When a conversation starts, the node registers its tools with jarvisd (POST /api/v0/conversation/start): for every command it sends get_command_schema() (description, rules, examples, antipatterns) and to_openai_tool_schema() (the tool definition). Then it sends the text.
jarvisd builds the prompt from those schemas. On the text path it leaves out _refinable parameters and fills them in with a second, focused call once the tool is picked (see The refinable Flag). The model:
- Reads the command descriptions, parameters, examples, rules, and antipatterns
- Selects the best-matching command (or answers directly if
allow_direct_answer=True) - Extracts parameter values from the voice command
- Returns a tool call with the command name and arguments
jarvisd returns the tool call to the node in its HTTP response.
3. Post-Process Tool Call (on the node)¶
Before it runs the tool call, the node gives post_process_tool_call() a chance to fix common model mistakes.
def post_process_tool_call(self, args: dict, voice_command: str) -> dict:
# Fix: LLM sometimes passes action="delete" instead of "trash"
if args.get("action") == "delete":
args["action"] = "trash"
# Fix: LLM sometimes forgets the query for play action
if args.get("action") == "play" and not args.get("query"):
args["query"] = self._extract_query_from_utterance(voice_command)
return args
This method receives the raw voice command as a second argument, which is useful for extracting data the model missed.
4. Execute (Orchestration)¶
execute() on IJarvisCommand orchestrates the validation pipeline. You do not override this method. The node builds a secrets dict from its secret store and passes it in (None skips secret enforcement, e.g. in tests).
def execute(self, request_info: RequestInformation, *, secrets=None, **kwargs) -> CommandResponse:
# 1. Secret presence check (only when secrets was provided)
# 2. Required-param presence check
# 3. Image parameters -> list[JarvisImage] (a bad image is a validation_error)
results = self.validate_call(**kwargs)
errors = [r for r in results if not r.success]
if errors:
return CommandResponse.validation_error(errors)
# Apply auto-corrections
for r in results:
if r.suggested_value is not None:
kwargs[r.param_name] = r.suggested_value
if secrets is not None:
return self.run(request_info, secrets=secrets, **kwargs)
return self.run(request_info, **kwargs)
5. Secret Validation¶
When the host passes a secrets dict, execute() checks that every secret in required_secrets with required=True has a non-empty value in that dict:
if secrets is not None:
missing = [
s.key for s in self.required_secrets
if s.required and not secrets.get(s.key)
]
if missing:
raise MissingSecretsError(missing)
If secrets are missing, a MissingSecretsError is raised. The node catches it, like any exception from execute(), and posts a failed tool result (Missing required secrets: <keys>) to jarvisd. The user hears the model's reply to that failure, or the node's own "Sorry, that didn't work: Missing required secrets: ...". Nothing tells the user where to set them.
6. Parameter Presence Validation¶
execute() then checks that every parameter with required=True is present in kwargs:
missing_params = [
p.name for p in self.parameters if p.required and kwargs.get(p.name) is None
]
if missing_params:
raise ValueError(f"Missing required params: {', '.join(missing_params)}")
7. Value Validation¶
validate_call() runs three checks on each parameter value:
- Type validation -- is the value the right Python type?
- Enum validation -- if
enum_valuesis set, is the value in the list? - Custom validation -- if a
validation_functionis defined, does it pass?
The default implementation loops over all parameters:
def validate_call(self, **kwargs) -> list[ValidationResult]:
results = []
for param in self.parameters:
value = kwargs.get(param.name)
if value is None:
continue
is_valid, error_msg = param.validate(value)
if not is_valid:
results.append(ValidationResult(
success=False,
param_name=param.name,
command_name=self.command_name,
message=error_msg,
valid_values=param.enum_values,
))
return results
Override for cross-parameter or context-dependent validation:
def validate_call(self, **kwargs) -> list[ValidationResult]:
results = super().validate_call(**kwargs)
# Custom: verify entity_id exists in Home Assistant
entity_id = kwargs.get("entity_id")
if entity_id and not self._entity_exists(entity_id):
results.append(ValidationResult(
success=False,
param_name="entity_id",
command_name=self.command_name,
message=f"Device '{entity_id}' not found",
valid_values=self._get_known_entities(),
))
return results
If any result has success=False, execute() returns validation_error straight away and run() is not called. Suggestions are applied only when every result passed.
8. Auto-Correction¶
When all checks pass, any ValidationResult with a suggested_value replaces that value in kwargs before run() is called:
This is useful for fuzzy matching. For example, if the user says "turn on the living room light" and the entity is light.living_room_main, your validate_call() can return a passing result with a suggestion:
results.append(ValidationResult(
success=True,
param_name="entity_id",
command_name=self.command_name,
suggested_value="light.living_room_main",
))
9. Run¶
Finally, your run() method executes with validated, potentially auto-corrected parameters:
def run(self, request_info: RequestInformation, **kwargs) -> CommandResponse:
# Parameters are validated and corrected at this point
city = kwargs.get("city", "default")
return CommandResponse.success_response(context_data={"city": city})
10. Result Back to jarvisd¶
The node turns the CommandResponse into a tool result (success, message, context, error, the actions buttons inside context, and referenceable_items; metadata stays on the node) and posts it to jarvisd:
wait_for_input=False(for examplefinal_response()): the node asksjarvisdfor a streamed spoken reply, whichjarvisd's model writes from the result and synthesizes, and plays it as it arrives. The conversation ends.wait_for_input=True: the node gets a text reply back. If the command succeeded and set amessage, the node speaks the command's ownmessageinstead of the model's text; otherwise the model's reply. Then it listens for a follow-up.- Every tool call failed: the node speaks "Sorry, that didn't work:
" rather than trusting the model's reply (unless a streamed reply already played). - If
on_response_completeis set, the node calls it after the reply has been spoken.
Buttons (actions) travel to jarvisd inside the result; when the user taps one, the tap comes back to the node's handle_action() (see Response Patterns).
On the fast path (step 1) none of this happens: there is no model turn, and the node speaks spoken_response or message itself.
Installation Lifecycle¶
When a command is first installed on a node, a separate lifecycle runs.
install_command.py Flow¶
flowchart TD
A[install_command.py] --> B[Discover command classes]
B --> C[Run DB migrations]
C --> R{--skip-deps?}
R -->|No| D[Resolve dependencies]
D --> E{Conflicts?}
E -->|Yes| F[Print conflict details, exit 1]
E -->|No| G[Write custom-requirements.txt]
G --> H[Write dependency-snapshot.json]
H --> I{--dry-run-deps?}
I -->|Yes| J[Print resolved deps, exit]
I -->|No| K[pip install -r custom-requirements.txt]
R -->|Yes| K2[Skip dependency resolution]
K --> L[For each command:]
K2 --> L
L --> M[Read all_possible_secrets]
M --> N[Seed empty rows in secrets DB]
N --> O[Done]
The dependency resolver collects required_packages from all commands, merges version constraints, and checks compatibility against requirements.txt. If two commands need the same package with incompatible versions, the install fails with a clear error.
# Install all commands (resolve deps + seed secrets)
python scripts/install_command.py --all
# Install a specific command
python scripts/install_command.py get_weather
# List commands and their secrets
python scripts/install_command.py --list
# Resolve dependencies only, don't install
python scripts/install_command.py --all --dry-run-deps
# Skip dependency resolution
python scripts/install_command.py --all --skip-deps
init_data.py Flow¶
For commands that need first-install setup (like fetching device lists or registering with external services), run this from a shell on the node, in its directory (/opt/jarvis-node on an installed Pi). my_command stands for your command_name:
This calls the command's init_data() method, which can run interactive setup (input() works only because you run it in a terminal):
def init_data(self) -> dict:
# Interactive setup: prompt for URL, authenticate, list devices
url = input("Service URL: ")
# ... setup logic ...
return {"status": "success", "devices_found": 5}
required_packages and Dependency Resolution¶
When commands declare required_packages, the install script resolves all command dependencies together:
@property
def required_packages(self) -> List[JarvisPackage]:
return [
JarvisPackage("music-assistant-client", ">=1.3.0"),
]
The resolver:
- Collects all
required_packagesfrom enabled commands - Normalizes package names (PEP 503: lowercase, hyphens)
- Checks compatibility across commands and against
requirements.txt - Writes
custom-requirements.txt(merged pip specs) anddependency-snapshot.json(for future command-store compatibility) - Installs via
pip install -r custom-requirements.txt
If a command package is already in requirements.txt with compatible constraints, base wins (no duplicate entry). If incompatible, the resolver reports which commands and base requirements conflict.
Vendored packages: Commands that need a patched or forked pure-Python library can vendor it in vendor/<command_name>/<package_name>/ and manage sys.path themselves. Vendored packages are not subject to dependency resolution.
Validation Errors¶
A validation_error response goes back to jarvisd like any failed tool result, with the errors and their valid_values in its context:
flowchart TD
A[validation_error result posted to jarvisd] --> B{Model's next step}
B -->|Calls the tool again with a valid value| C[Node runs execute again]
B -->|Same call with the same arguments| D[Node stops and speaks the error]
B -->|Calls request_validation| E[jarvisd asks the user]
E --> G[User answers]
G --> H[Model continues with the answer]
The valid_values list in ValidationResult matters: it tells the model what the correct options are, so it can retry without bothering the user.
Multi-Turn Conversation Flow¶
When a command returns wait_for_input=True, the conversation stays open. The node drives every exchange:
sequenceDiagram
participant User
participant Node
participant J as jarvisd
participant LLM as Model (in jarvisd)
Node->>J: POST /conversation/start (tool schemas)
User->>Node: "What's 5 plus 3?"
Note over Node: pre_route(): no match
Node->>J: POST /voice/command (text)
J->>LLM: Model turn with the node's tools
LLM->>J: calculate(num1=5, num2=3, operation=add)
J-->>Node: tool_calls
Note over Node: execute() -> follow_up_response(result=8)
Node->>J: POST /voice/command/continue (tool results)
J->>LLM: Model turn with the result
J-->>Node: "5 plus 3 equals 8"
Node->>User: speaks the command's message
Note over Node: wait_for_input=True, keeps listening
User->>Node: "Now multiply that by 2"
Node->>J: POST /voice/command (same conversation)
J->>LLM: Model turn (with conversation history)
LLM->>J: calculate(num1=8, num2=2, operation=multiply)
J-->>Node: tool_calls
Note over Node: execute() -> follow_up_response(result=16)
Node->>J: POST /voice/command/continue (tool results)
J-->>Node: "8 times 2 equals 16"
Node->>User: speaks the command's message
jarvisd keeps the conversation history, so the model can resolve references like "that" to the previous result. The exact endpoints vary (the node uses the streaming variants where it can); the shape does not: the node asks, jarvisd answers with tool calls or a reply, and the node runs the tools.