Secrets Deep Dive¶
Secrets store configuration that commands need at runtime -- API keys, tokens, user preferences, and service URLs. They are managed through an encrypted SQLite database on each node and synchronized with the mobile app via settings snapshots. JarvisSecret and JarvisStorage are imported from jarvis_command_sdk.
JarvisSecret Constructor¶
JarvisSecret(
key: str, # Unique identifier (e.g., "OPENWEATHER_API_KEY")
description: str, # Human-readable description
scope: str, # "integration" or "user"
value_type: str, # "string", "int", "bool" or "user"
required: bool = True, # Whether command fails without it
is_sensitive: bool = True, # Whether value appears in settings snapshots
friendly_name: str | None = None, # Display name for mobile UI
enum_values: list[str] | None = None, # Allowed values (mobile shows a dropdown)
presets: dict[str, dict[str, str]] | None = None, # Values to fill in per enum choice
)
enum_values needs value_type="string". presets needs enum_values: its keys are enum values, and choosing one in the app fills in the related secrets, e.g. the IMAP host and port for a mail provider:
JarvisSecret(
"EMAIL_PROVIDER", "Mail provider", "integration", "string",
is_sensitive=False,
enum_values=["gmail", "icloud"],
presets={
"icloud": {"IMAP_HOST": "imap.mail.me.com", "IMAP_PORT": "993"},
},
)
Scopes¶
"integration" -- One Value per Node¶
Use for config that is the same for everyone who uses the node:
- API keys (OpenWeather, financial data, etc.)
- OAuth tokens (Gmail, Spotify)
- Service URLs (Home Assistant, Music Assistant)
- Shared preferences (unit system, language)
JarvisSecret(
"OPENWEATHER_API_KEY",
"Open Weather API Key",
"integration", # Everyone on this node uses the same key
"string",
)
Secrets live in each node's own encrypted database, so an integration value is set per node. To reuse it, the mobile app's node settings can copy a service's configured secrets to your other nodes (the target nodes need their encryption key imported first).
"user" -- Per-User¶
Use for credentials or preferences that differ per household member:
- Personal email credentials (each family member has their own)
- Personal OAuth tokens
- Per-user preferences
JarvisSecret(
"EMAIL_ADDRESS",
"Your email address",
"user", # Each household member has their own value
"string",
is_sensitive=False,
friendly_name="Email Address",
)
Invalid scope values raise ValueError at construction time.
Value Types¶
| Type | Description | Example |
|---|---|---|
"string" |
Text values | API keys, URLs, tokens |
"int" |
Integer values | Port numbers, thresholds |
"bool" |
Boolean values | Feature flags |
"user" |
A household member (the app shows a member picker; the value is the member's user id as a string) | Who a background agent posts notifications to |
Invalid value types raise ValueError at construction time.
All values are stored as strings internally. Use appropriate conversion in your run() method:
secrets = kwargs.get("secrets") or {}
port = int(secrets.get("MY_SERVICE_PORT") or "8080")
enabled = secrets.get("FEATURE_FLAG") == "true"
Sensitivity¶
is_sensitive=True (Default)¶
Sensitive secrets are not included in settings snapshots sent to the mobile app. The mobile app shows a masked placeholder and allows users to enter a new value, but never sees the current value.
Use for: API keys, passwords, tokens, client secrets.
JarvisSecret(
"FINANCE_API_KEY", "API key", "integration", "string",
is_sensitive=True, # Default -- value never leaves the node
)
is_sensitive=False¶
Non-sensitive values are included in settings snapshots. The mobile app can display and edit the current value.
Use for: URLs, locations, unit preferences, display names.
JarvisSecret(
"OPENWEATHER_UNITS", "Imperial, Metric, or Kelvin", "integration", "string",
is_sensitive=False, # Mobile app shows current value
friendly_name="Units",
)
Friendly Names¶
The friendly_name is what users see in the mobile settings UI instead of the raw key:
JarvisSecret("OPENWEATHER_API_KEY", ..., friendly_name="API Key")
JarvisSecret("OPENWEATHER_UNITS", ..., friendly_name="Units")
JarvisSecret("OPENWEATHER_LOCATION", ..., friendly_name="Default Location")
In the mobile app, under the "OpenWeather" service group, users see:
If friendly_name is None, the mobile app falls back to displaying the raw key name.
Installation Flow¶
install_command.py¶
The install script discovers all command classes, runs database migrations, and seeds the secrets table:
# List all commands and their secrets
python scripts/install_command.py --list
# Install all commands
python scripts/install_command.py --all
# Install a single command
python scripts/install_command.py get_weather
The install script:
- Finds the command class
- Reads
all_possible_secrets(falls back torequired_secrets) - Creates empty-value rows in the secrets database for each secret
- Never overwrites existing values -- safe to re-run
all_possible_secrets¶
When required_secrets is config-dependent, override all_possible_secrets to declare every secret the command could ever need:
@property
def required_secrets(self) -> List[IJarvisSecret]:
# Returns different secrets based on EMAIL_PROVIDER setting
provider = get_email_provider()
if provider == "imap":
return [imap_username, imap_password]
else:
return [gmail_client_id]
@property
def all_possible_secrets(self) -> List[IJarvisSecret]:
# Always returns ALL variants -- used by install_command.py
return [
email_provider,
gmail_client_id, gmail_access_token, gmail_refresh_token,
imap_host, imap_port, imap_username, imap_password,
smtp_host, smtp_port,
]
This ensures install_command.py seeds all possible secret rows, regardless of the current configuration.
Setting Secret Values¶
Mobile App (Settings Sync)¶
The primary way users set secrets:
- Open the mobile app
- Navigate to Nodes tab, tap a node
- Find the command's service group
- Enter values in the settings form
- The mobile app pushes the encrypted settings to the node through your
jarvisdserver, and the node stores them
set_secret.py (Dev/CLI)¶
For development or headless setup, from a shell in the node's directory (/opt/jarvis-node on an installed Pi):
python utils/set_secret.py set --key OPENWEATHER_API_KEY --value "your-key-here" --scope integration
python utils/set_secret.py set --key OPENWEATHER_LOCATION --value "Miami,FL,US" --scope integration
--scope defaults to integration; the other scope is user. The tool also has get, delete and list subcommands.
Programmatically¶
In store_auth_values() or init_data(), use JarvisStorage:
from jarvis_command_sdk import JarvisStorage
JarvisStorage("email").set_secret("GMAIL_ACCESS_TOKEN", token_value) # scope "integration"
JarvisStorage("email", secret_scope="user").set_secret("EMAIL_ADDRESS", address) # current user
A "user" scope resolves to the current user (the speaker, or the user who connected the account). If the host registered no storage backend, get_secret() returns None and set_secret() does nothing.
Reading Secrets at Runtime¶
The node passes your secrets into run() as the secrets keyword: every secret in required_secrets (required or not) that has a value, read for the current user where the scope is "user":
def run(self, request_info, **kwargs) -> CommandResponse:
secrets = kwargs.get("secrets") or {}
api_key = secrets.get("OPENWEATHER_API_KEY")
if not api_key:
return CommandResponse.error_response(
error_details="OpenWeather API key is not configured. Set it in your settings.",
)
location = secrets.get("OPENWEATHER_LOCATION")
# ...
Anywhere else (or for a secret not in required_secrets), read it with JarvisStorage:
from jarvis_command_sdk import JarvisStorage
location = JarvisStorage("get_weather").get_secret("OPENWEATHER_LOCATION")
Important: Even though the secret presence check runs before run(), it only verifies that required=True secrets are present. For optional secrets, you must check manually.
services.secret_service
Older commands call get_secret_value() and set_secret() from services.secret_service. That module exists only inside the Python node, so such a command fails on a desktop agent. Use the secrets keyword or JarvisStorage.
Secret Validation in the Pipeline¶
The execute() method on IJarvisCommand checks the host-supplied secrets dict before your run():
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 any required secret is missing, a MissingSecretsError is raised and run() is never called. The node catches it like any exception from a tool and sends jarvisd a failed tool result with the message Missing required secrets: <keys>. The user hears the model's reply to that failure or, when the node falls back to its own message, "Sorry, that didn't work: Missing required secrets: ...". Nothing points the user to the settings, so a clear friendly_name and setup_guide help.
Patterns¶
API Key + Non-Sensitive Config¶
The most common pattern -- one sensitive key, one or more non-sensitive preferences:
@property
def required_secrets(self) -> List[IJarvisSecret]:
return [
JarvisSecret("MY_API_KEY", "API key", "integration", "string",
friendly_name="API Key"),
JarvisSecret("MY_DEFAULT_REGION", "Default region", "integration", "string",
is_sensitive=False, friendly_name="Region"),
]
Service URL + Token¶
For self-hosted services where the URL varies by installation:
@property
def required_secrets(self) -> List[IJarvisSecret]:
return [
JarvisSecret("SERVICE_URL", "Service URL (e.g., http://192.168.1.50:8080)",
"integration", "string", is_sensitive=False, friendly_name="URL"),
JarvisSecret("SERVICE_TOKEN", "Auth token",
"integration", "string", friendly_name="Token"),
]
Optional Secrets with Fallbacks¶
For secrets with runtime fallbacks:
@property
def required_secrets(self) -> List[IJarvisSecret]:
return [
JarvisSecret("WEATHER_API_KEY", "Required API key",
"integration", "string", required=True),
JarvisSecret("WEATHER_LOCATION", "Optional default location",
"integration", "string", required=False, is_sensitive=False),
]
def run(self, request_info, **kwargs):
city = kwargs.get("city")
if not city:
city = (kwargs.get("secrets") or {}).get("WEATHER_LOCATION")
if not city:
city = self._detect_location_from_ip()
# ...
Multiple Providers (Config-Variant)¶
When the same command supports multiple backends:
@property
def required_secrets(self) -> List[IJarvisSecret]:
provider = get_email_provider() # Reads EMAIL_PROVIDER secret
base = [
JarvisSecret("EMAIL_PROVIDER", "gmail or imap", "integration", "string",
required=False, is_sensitive=False),
]
if provider == "imap":
base.extend([...imap secrets...])
else:
base.extend([...gmail secrets...])
return base
@property
def all_possible_secrets(self) -> List[IJarvisSecret]:
return [...all gmail secrets...] + [...all imap secrets...]