Tutorial: API Integration Command¶
This tutorial builds a stock price command that calls an external API. You will learn how to manage API keys with JarvisSecret, make HTTP requests in run(), handle errors gracefully, and use associated_service and antipatterns for a polished integration.
Prerequisites: Completed the Simple Command Tutorial.
The Plan¶
We will build a get_stock_price command that:
- Accepts a stock ticker symbol
- Looks up the current price via a financial data API
- Requires an API key stored as a secret
- Groups with other financial commands in the mobile settings UI
- Distinguishes itself from the web search command
Step 1: File and Imports¶
Create jarvis-node-setup/commands/stock_price_command.py:
from typing import List
import httpx
from jarvis_log_client import JarvisLogger
from core.ijarvis_command import IJarvisCommand, CommandExample, CommandAntipattern
from core.ijarvis_parameter import JarvisParameter
from core.ijarvis_secret import IJarvisSecret, JarvisSecret
from core.command_response import CommandResponse
from core.request_information import RequestInformation
from services.secret_service import get_secret_value
logger = JarvisLogger(service="jarvis-node")
Notable additions compared to the dice command:
httpxfor HTTP requests (or userequests)JarvisLoggerfor structured logging (never useprint())get_secret_valuefor reading secrets at runtimeCommandAntipatternfor disambiguation
Step 2: Declare Secrets¶
This command needs an API key. We declare it as a JarvisSecret:
class StockPriceCommand(IJarvisCommand):
@property
def required_secrets(self) -> List[IJarvisSecret]:
return [
JarvisSecret(
key="FINANCE_API_KEY",
description="API key for the financial data provider",
scope="integration",
value_type="string",
required=True,
is_sensitive=True,
friendly_name="API Key",
),
JarvisSecret(
key="FINANCE_DEFAULT_CURRENCY",
description="Default currency for prices (e.g., USD, EUR)",
scope="integration",
value_type="string",
required=False,
is_sensitive=False,
friendly_name="Default Currency",
),
]
Key choices:
scope="integration"means this key is shared across all nodes in the household. Use"node"for per-node config (like a default location).is_sensitive=Truefor the API key means it won't appear in settings snapshots sent to the mobile app. The currency preference usesis_sensitive=Falseso the mobile app can display it.friendly_nameis what users see in the mobile settings UI instead of the raw key name.
Step 3: Properties and Parameters¶
@property
def command_name(self) -> str:
return "get_stock_price"
@property
def description(self) -> str:
return (
"Get current stock price by ticker symbol. "
"For company research, earnings, or financial news, use search_web instead."
)
@property
def keywords(self) -> List[str]:
return ["stock", "price", "ticker", "shares", "market", "quote"]
@property
def parameters(self) -> List[JarvisParameter]:
return [
JarvisParameter(
"ticker",
"string",
required=True,
description="Stock ticker symbol (e.g., AAPL, GOOGL, TSLA)",
),
]
Step 4: Associated Service¶
Group this command with other financial commands in the mobile settings UI:
If you later add a get_stock_news command with the same associated_service, they will appear together in the mobile app's settings screen, sharing the same API key configuration section.
Step 5: Antipatterns¶
Tell the LLM what NOT to use this command for:
@property
def antipatterns(self) -> List[CommandAntipattern]:
return [
CommandAntipattern(
command_name="search_web",
description=(
"Company research, earnings reports, financial news, "
"market analysis. Use search_web for those."
),
),
]
This helps the LLM distinguish between "What's Apple's stock price?" (this command) and "What were Apple's Q3 earnings?" (web search).
Step 6: Examples¶
def generate_prompt_examples(self) -> List[CommandExample]:
return [
CommandExample(
voice_command="What's Apple's stock price?",
expected_parameters={"ticker": "AAPL"},
is_primary=True,
),
CommandExample(
voice_command="How is Tesla doing?",
expected_parameters={"ticker": "TSLA"},
),
CommandExample(
voice_command="Check the price of Google stock",
expected_parameters={"ticker": "GOOGL"},
),
]
def generate_adapter_examples(self) -> List[CommandExample]:
return [
CommandExample("What's Apple's stock price?", {"ticker": "AAPL"}, is_primary=True),
CommandExample("How is Tesla doing?", {"ticker": "TSLA"}),
CommandExample("Check the price of Google", {"ticker": "GOOGL"}),
CommandExample("What's Amazon trading at?", {"ticker": "AMZN"}),
CommandExample("Microsoft stock price", {"ticker": "MSFT"}),
CommandExample("How much is NVIDIA stock?", {"ticker": "NVDA"}),
CommandExample("Give me a quote on Meta", {"ticker": "META"}),
CommandExample("Stock price for Disney", {"ticker": "DIS"}),
CommandExample("What's the share price of Netflix?", {"ticker": "NFLX"}),
CommandExample("Check SPY", {"ticker": "SPY"}),
]
Step 7: Implement run()¶
def run(self, request_info: RequestInformation, **kwargs) -> CommandResponse:
ticker = kwargs.get("ticker", "").upper().strip()
if not ticker:
return CommandResponse.error_response(
error_details="Please specify a stock ticker symbol",
)
api_key = get_secret_value("FINANCE_API_KEY", "integration")
if not api_key:
return CommandResponse.error_response(
error_details="Finance API key is not configured. Set it in your node settings.",
)
currency = get_secret_value("FINANCE_DEFAULT_CURRENCY", "integration") or "USD"
try:
response = httpx.get(
f"https://api.example.com/v1/quote/{ticker}",
params={"apikey": api_key, "currency": currency},
timeout=10.0,
)
if response.status_code == 404:
return CommandResponse.error_response(
error_details=f"Ticker '{ticker}' not found. Check the symbol and try again.",
context_data={"ticker": ticker, "error": "not_found"},
)
response.raise_for_status()
data = response.json()
price = data.get("price")
change = data.get("change")
change_pct = data.get("change_percent")
name = data.get("name", ticker)
logger.info("Stock price fetched", ticker=ticker, price=price)
return CommandResponse.final_response(
context_data={
"ticker": ticker,
"name": name,
"price": price,
"change": change,
"change_percent": change_pct,
"currency": currency,
"message": (
f"{name} ({ticker}) is at {currency} {price}, "
f"{'up' if change >= 0 else 'down'} {abs(change_pct):.1f}%"
),
}
)
except httpx.TimeoutException:
logger.error("Stock API timeout", ticker=ticker)
return CommandResponse.error_response(
error_details="The financial data service is not responding. Try again in a moment.",
)
except httpx.HTTPStatusError as e:
logger.error("Stock API error", ticker=ticker, status=e.response.status_code)
return CommandResponse.error_response(
error_details=f"Failed to fetch stock data: HTTP {e.response.status_code}",
)
except Exception as e:
logger.error("Stock price fetch failed", ticker=ticker, error=str(e))
return CommandResponse.error_response(
error_details=f"Unable to fetch stock price: {str(e)}",
)
Error Handling Best Practices¶
- Check for missing secrets early -- return a clear error message pointing to settings
- Handle specific HTTP errors -- 404 for bad tickers, timeouts, general HTTP errors
- Always use
logger-- structured logging with context, neverprint() - Return user-friendly error messages -- the LLM uses these to generate spoken responses
- Include
context_dataeven in errors -- helps debugging and lets the LLM provide context
Step 8: Install and Test¶
# Install (seeds secret rows in the DB)
python scripts/install_command.py get_stock_price
# Set the API key
python utils/set_secret.py FINANCE_API_KEY "your-api-key-here" integration
# Test with E2E
python test_command_parsing.py -c get_stock_price
Adding Rules¶
If the LLM makes common mistakes, add rules:
@property
def rules(self) -> List[str]:
return [
"Extract the ticker symbol from company names (Apple -> AAPL, Google -> GOOGL)",
"If the user says a company name, use the common ticker for that company",
]
@property
def critical_rules(self) -> List[str]:
return [
"The ticker parameter must be a valid stock symbol, not a company name",
]
Adding Post-Processing¶
If the LLM sometimes passes a company name instead of a ticker, you can fix it:
_COMPANY_TO_TICKER = {
"apple": "AAPL", "google": "GOOGL", "tesla": "TSLA",
"amazon": "AMZN", "microsoft": "MSFT", "meta": "META",
}
def post_process_tool_call(self, args: dict, voice_command: str) -> dict:
ticker = args.get("ticker", "")
# If the LLM passed a company name, map it to a ticker
mapped = self._COMPANY_TO_TICKER.get(ticker.lower())
if mapped:
args["ticker"] = mapped
return args
What's Next¶
- OAuth Command Tutorial -- for commands needing user authorization
- Secrets Deep Dive -- advanced secret patterns (scopes, config variants)
- Response Patterns -- follow-up flows, interactive buttons