Skip to content

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 commands/stock_price_command.py in your jarvis-node-setup checkout:

from typing import List

import httpx

from jarvis_command_sdk import (
    CommandAntipattern,
    CommandExample,
    CommandResponse,
    IJarvisCommand,
    IJarvisSecret,
    JarvisPackage,
    JarvisParameter,
    JarvisSecret,
    RequestInformation,
    get_logger,
)

Notable additions compared to the dice command:

  • httpx for HTTP requests (declared in required_packages below, so the node installs it)
  • get_logger for logging (never use print())
  • JarvisSecret to declare the API key
  • CommandAntipattern for disambiguation

Get the logger inside run(), not at import time: the node registers its own logger only after it has imported the commands, and a logger fetched earlier stays on Python's standard logging. Pass plain messages (no keyword arguments), which both loggers accept.

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 one value for everyone who uses this node. Each node keeps its own copy; the mobile app can copy a service's settings to other nodes. Use "user" for per-user values like personal credentials.
  • is_sensitive=True for the API key means it won't appear in settings snapshots sent to the mobile app. The currency preference uses is_sensitive=False so the mobile app can display it.
  • friendly_name is 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)",
            ),
        ]

Declare the HTTP library too, so the install resolves and installs it:

    @property
    def required_packages(self) -> List[JarvisPackage]:
        return [JarvisPackage("httpx", ">=0.25")]

Step 4: Associated Service

Group this command with other financial commands in the mobile settings UI:

    @property
    def associated_service(self) -> str | None:
        return "Financial Data"

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:
        logger = get_logger(__name__)
        secrets = kwargs.get("secrets") or {}
        ticker = kwargs.get("ticker", "").upper().strip()
        if not ticker:
            return CommandResponse.error_response(
                error_details="Please specify a stock ticker symbol",
            )

        api_key = secrets.get("FINANCE_API_KEY")
        if not api_key:
            return CommandResponse.error_response(
                error_details="Finance API key is not configured. Set it in your node settings.",
            )

        currency = secrets.get("FINANCE_DEFAULT_CURRENCY") 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(f"Stock price fetched: {ticker} {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(f"Stock API timeout: {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(f"Stock API error: {ticker} HTTP {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(f"Stock price fetch failed: {ticker}: {e}")
            return CommandResponse.error_response(
                error_details=f"Unable to fetch stock price: {str(e)}",
            )

The secret values come in as the secrets keyword: the node reads every secret in required_secrets from its encrypted store and passes the ones that are set. Outside run() (in store_auth_values(), for example), read them with JarvisStorage("get_stock_price").get_secret("FINANCE_API_KEY").

Error Handling Best Practices

  1. Check optional secrets in run() -- for a required=True secret that is missing, execute() raises MissingSecretsError before run() is called, and the user hears that the request didn't work, naming the missing key. The check in run() above only matters when a host calls execute() without secrets (tests, for example); for optional secrets like the currency, fall back to a default
  2. Handle specific HTTP errors -- 404 for bad tickers, timeouts, general HTTP errors
  3. Always use logger -- never print()
  4. Return user-friendly error messages -- the LLM uses these to generate spoken responses
  5. Include context_data even in errors -- helps debugging and lets the LLM provide context

Step 8: Install and Test

# Install (resolves packages, seeds secret rows in the DB), then restart the node
python scripts/install_command.py get_stock_price
sudo systemctl restart jarvis-node

# Set the API key (on the node; users normally enter it in the mobile app)
python utils/set_secret.py set --key FINANCE_API_KEY --value "your-api-key-here" --scope 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