Exposing the Wallet to AI agents: Connecting the Wise (TransferWise) API to Home Assistant and Antigravity (agy)

By | June 29, 2026

In a developer-first smart environment, we bridge physical sensors, AC systems, and mesh radios. But we can go further: connecting financial APIs directly to our terminal workspace. By integrating the Wise (formerly TransferWise) API with Antigravity (agy) and Home Assistant, we can check multi-currency account balances and execute secure payments directly from our terminal or off-grid radio mesh.

This guide details how this integration works under the hood, how the Python CLI script handles Wise’s Strong Customer Authentication (SCA), and how Home Assistant and agy are configured to interact with it.

1. The Architecture: Local Terminal to Financial Gateway

Connecting financial accounts to an AI agent requires extreme security. We utilize a Personal Access Token with restricted scopes generated in the Wise developer dashboard, combined with RSA private key signing for 2-Factor Authentication (2FA).

  • Direct CLI Interface: The developer or the AI assistant runs /home/gvoina/scripts/wise_wallet.py directly from the shell.
  • Home Assistant Dashboard: Home Assistant runs the same script mounted inside its container at /config/wise_wallet.py to poll balances every hour.
  • Cryptographic 2FA Signing: When a payment is initiated, Wise’s gateway challenges the request. The script intercepts this, signs it using a local private key (wise_private_key.pem), and completes the transfer automatically.

2. The CLI Bridge: wise_wallet.py

This script, located at wise_wallet.py, handles all requests. Crucially, it manages the SCA (Strong Customer Authentication) signing loop:

  1. The script makes a POST request to fund a transfer.
  2. The Wise API returns an HTTP 403 Forbidden with a header named x-2fa-approval containing a one-time challenge UUID.
  3. The script reads a local private RSA key (/home/gvoina/scripts/wise_private_key.pem), signs the challenge UUID using SHA-256 and PKCS1v15 padding, and encodes it in Base64.
  4. The script re-submits the request with the headers x-2fa-approval and x-2fa-signature to authorize and execute the payment.

Here is the complete code of the CLI bridge:

#!/usr/bin/env python3
# =============================================================================
# Wise (formerly TransferWise) API CLI Wallet Bridge
# Manages multi-currency balance queries and initiates transfers.
# Support for 2FA signature signing via a local private key.
# =============================================================================
import os
import sys
import json
import uuid
import requests
import base64

script_dir = os.path.dirname(os.path.realpath(__file__))

def get_path(default_path, filename):
    if os.path.exists(default_path):
        return default_path
    return os.path.join(script_dir, filename)

TOKEN_PATH = get_path("/home/gvoina/scripts/wise_token.txt", "wise_token.txt")
PROFILE_PATH = get_path("/home/gvoina/scripts/wise_profile.txt", "wise_profile.txt")
KEY_PATH = get_path("/home/gvoina/scripts/wise_private_key.pem", "wise_private_key.pem")
ENV_PATH = get_path("/home/gvoina/scripts/wise_env.txt", "wise_env.txt")

def get_env():
    if os.path.exists(ENV_PATH):
        try:
            with open(ENV_PATH, "r", encoding="utf-8") as f:
                return f.read().strip().lower()
        except Exception:
            pass
    return os.getenv("WISE_ENV", "production").lower()

def get_base_url():
    env = get_env()
    if env == "sandbox":
        return "https://api.sandbox.transferwise.tech"
    return "https://api.transferwise.com"

def get_token():
    token = os.getenv("WISE_TOKEN")
    if not token and os.path.exists(TOKEN_PATH):
        try:
            with open(TOKEN_PATH, "r", encoding="utf-8") as f:
                token = f.read().strip()
        except Exception as e:
            print(f"Error reading token file: {e}", file=sys.stderr)
    return token

def get_profile_id(headers, base_url):
    if os.path.exists(PROFILE_PATH):
        try:
            with open(PROFILE_PATH, "r", encoding="utf-8") as f:
                return int(f.read().strip())
        except Exception:
            pass
    
    try:
        url = f"{base_url}/v1/profiles"
        res = make_request("GET", url, headers)
        for profile in res:
            if profile.get("type") == "personal":
                profile_id = profile.get("id")
                with open(PROFILE_PATH, "w", encoding="utf-8") as f:
                    f.write(str(profile_id))
                return profile_id
        if res:
            profile_id = res[0].get("id")
            with open(PROFILE_PATH, "w", encoding="utf-8") as f:
                f.write(str(profile_id))
            return profile_id
    except Exception as e:
        print(f"Error fetching profiles: {e}", file=sys.stderr)
    return None

def sign_2fa_token(token):
    try:
        from cryptography.hazmat.primitives import hashes
        from cryptography.hazmat.primitives.asymmetric import padding
        from cryptography.hazmat.primitives.serialization import load_pem_private_key
    except ImportError:
        print("Error: cryptography library is required for 2FA signing.", file=sys.stderr)
        sys.exit(1)
        
    if not os.path.exists(KEY_PATH):
        print(f"Error: 2FA signing key not found at {KEY_PATH}.", file=sys.stderr)
        sys.exit(1)
        
    try:
        with open(KEY_PATH, "rb") as f:
            private_key = load_pem_private_key(f.read(), password=None)
        signature = private_key.sign(
            token.encode("utf-8"),
            padding.PKCS1v15(),
            hashes.SHA256()
        )
        return base64.b64encode(signature).decode("utf-8")
    except Exception as e:
        print(f"Failed to sign 2FA token: {e}", file=sys.stderr)
        sys.exit(1)

def make_request(method, url, headers, json_data=None):
    res = requests.request(method, url, headers=headers, json=json_data, timeout=15)
    
    if res.status_code == 403 and "x-2fa-approval" in res.headers:
        approval_token = res.headers["x-2fa-approval"]
        signature = sign_2fa_token(approval_token)
        
        new_headers = headers.copy()
        new_headers["x-2fa-approval"] = approval_token
        new_headers["x-2fa-signature"] = signature
        
        res = requests.request(method, url, headers=new_headers, json=json_data, timeout=15)
        
    res.raise_for_status()
    return res.json() if res.content else {}

def cmd_balances(headers, base_url):
    profile_id = get_profile_id(headers, base_url)
    if not profile_id:
        print("Error: Could not retrieve Wise profile ID.", file=sys.stderr)
        sys.exit(1)
        
    url = f"{base_url}/v4/profiles/{profile_id}/balances?types=STANDARD"
    try:
        balances = make_request("GET", url, headers)
        print(json.dumps(balances, indent=2))
    except Exception as e:
        print(f"Error querying balances: {e}", file=sys.stderr)
        sys.exit(1)

def cmd_make_payment(headers, base_url, recipient_name, recipient_iban, amount, currency, reference):
    profile_id = get_profile_id(headers, base_url)
    if not profile_id:
        print("Error: Could not retrieve Wise profile ID.", file=sys.stderr)
        sys.exit(1)
        
    # 1. Create a quote
    print("Step 1: Creating transfer quote...")
    quote_url = f"{base_url}/v3/profiles/{profile_id}/quotes"
    quote_data = {
        "sourceCurrency": currency,
        "targetCurrency": currency,
        "sourceAmount": float(amount),
        "targetAmount": None,
        "payOut": "BALANCE"
    }
    try:
        quote = make_request("POST", quote_url, headers, quote_data)
        quote_id = quote.get("id")
        print(f"   Quote created: UUID {quote_id}")
    except Exception as e:
        print(f"Failed to create quote: {e}", file=sys.stderr)
        sys.exit(1)

    # 2. Create recipient
    print("Step 2: Locating or creating recipient...")
    recipient_url = f"{base_url}/v1/recipients"
    recipient_data = {
        "profile": profile_id,
        "type": "iban",
        "currency": currency,
        "accountHolderName": recipient_name,
        "details": {
            "legalType": "PRIVATE",
            "iban": recipient_iban
        }
    }
    try:
        recipient = make_request("POST", recipient_url, headers, recipient_data)
        recipient_id = recipient.get("id")
        print(f"   Recipient created: ID {recipient_id}")
    except Exception as e:
        print(f"Failed to create recipient: {e}", file=sys.stderr)
        sys.exit(1)

    # 3. Create transfer
    print("Step 3: Creating transfer transaction...")
    transfer_url = f"{base_url}/v1/transfers"
    transaction_id = str(uuid.uuid4())
    transfer_data = {
        "targetAccount": recipient_id,
        "quoteUuid": quote_id,
        "customerTransactionId": transaction_id,
        "details": {
            "reference": reference
        }
    }
    try:
        transfer = make_request("POST", transfer_url, headers, transfer_data)
        transfer_id = transfer.get("id")
        print(f"   Transfer created: ID {transfer_id}")
    except Exception as e:
        print(f"Failed to create transfer: {e}", file=sys.stderr)
        sys.exit(1)

    # 4. Fund the transfer from standard balance
    print("Step 4: Funding transfer from balance (SCA signature if required)...")
    fund_url = f"{base_url}/v3/transfers/{transfer_id}/payments"
    fund_data = {
        "type": "BALANCE"
    }
    try:
        payment = make_request("POST", fund_url, headers, fund_data)
        print("✅ Transfer successfully funded and executed!")
        print(json.dumps(payment, indent=2))
    except Exception as e:
        print(f"Failed to fund transfer: {e}", file=sys.stderr)
        sys.exit(1)

if __name__ == "__main__":
    token = get_token()
    if not token:
        print("Error: Wise API personal token not configured.", file=sys.stderr)
        print(f"Please save your token in {TOKEN_PATH} or export it as WISE_TOKEN.", file=sys.stderr)
        sys.exit(1)
        
    base_url = get_base_url()
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json"
    }
    
    if len(sys.argv) < 2:
        print("Usage:")
        print("  Query balances: python3 wise_wallet.py balance")
        print("  Make payment:   python3 wise_wallet.py payment <recipient_name> <recipient_iban> <amount> [currency] [reference]")
        sys.exit(1)
        
    cmd = sys.argv[1].lower()
    
    if cmd == "balance":
        cmd_balances(headers, base_url)
    elif cmd == "payment":
        if len(sys.argv) < 5:
            print("Usage: python3 wise_wallet.py payment <recipient_name> <recipient_iban> <amount> [currency] [reference]", file=sys.stderr)
            sys.exit(1)
        name = sys.argv[2]
        iban = sys.argv[3]
        amount = sys.argv[4]
        currency = sys.argv[5] if len(sys.argv) > 5 else "EUR"
        ref = sys.argv[6] if len(sys.argv) > 6 else "Sent via Antigravity"
        cmd_make_payment(headers, base_url, name, iban, amount, currency, ref)
    else:
        print(f"Unknown command: {cmd}", file=sys.stderr)
        sys.exit(1)

3. Home Assistant Configuration

To fetch balances automatically, we set up Command Line Sensors inside /home/gvoina/homeassistant/configuration.yaml.

Mount Path Considerations

Because Home Assistant runs in a Docker container, it cannot access the host directory /home/gvoina/scripts/ directly. We copy the script and its credential configuration files to the /home/gvoina/homeassistant/ directory. Since this folder is mounted inside the container as /config/, the files become available as /config/wise_wallet.py, /config/wise_token.txt, and /config/wise_profile.txt.

The script’s get_path function handles this transition seamlessly by falling back to its own directory when absolute host paths are missing.

configuration.yaml Snippet

command_line:
  - sensor:
      name: "Wise EUR Balance"
      command: "python3 /config/wise_wallet.py balance 2>/dev/null"
      value_template: >
        {% set eur = value_json | selectattr('currency', 'eq', 'EUR') | list | first %}
        {{ eur.amount.value if eur is defined and eur.amount is defined else 0 }}
      unit_of_measurement: "EUR"
      scan_interval: 3600

  - sensor:
      name: "Wise USD Balance"
      command: "python3 /config/wise_wallet.py balance 2>/dev/null"
      value_template: >
        {% set usd = value_json | selectattr('currency', 'eq', 'USD') | list | first %}
        {{ usd.amount.value if usd is defined and usd.amount is defined else 0 }}
      unit_of_measurement: "USD"
      scan_interval: 3600

  - sensor:
      name: "Wise RON Balance"
      command: "python3 /config/wise_wallet.py balance 2>/dev/null"
      value_template: >
        {% set ron = value_json | selectattr('currency', 'eq', 'RON') | list | first %}
        {{ ron.amount.value if ron is defined and ron.amount is defined else 0 }}
      unit_of_measurement: "RON"
      scan_interval: 3600

4. Workspace Customizations (AGENTS.md)

To teach agy how to use these commands, the following instructions are loaded into the agentic rulebook at .agents/AGENTS.md:

### Wise (TransferWise) Wallet Operations
You can query account balances and execute payments using:
* **Query balances**: `python3 /home/gvoina/scripts/wise_wallet.py balance`
* **Execute payment/transfer**: `python3 /home/gvoina/scripts/wise_wallet.py payment <recipient_name> <recipient_iban> <amount> [currency] [reference]`
  * *Tip*: This supports automatic 2FA signing if the private key `wise_private_key.pem` is configured in `/home/gvoina/scripts/`.

### Operating Procedures:
* **Financial Transactions**: For any payment or transfer command, always ask the developer for confirmation and explain the transfer details before executing it.

5. Security & Human-in-the-Loop Verification

Financial integrations require strict safety boundaries:

  1. Interactive Review: agy will never execute a payment without presenting the recipient name, IBAN, amount, and reference to the developer first, requesting explicit confirmation.
  2. Local Key Storage: The private RSA key (wise_private_key.pem) is kept strictly in the local workspace. No key material is transmitted over external channels.

6. Real-World Shell Session Example

Here is a mock shell output showing how the agent checks balances and handles a payment request:

Check Balances

Developer: Check my wallet balances.

agy: I will query your Wise multi-currency account.
[agy runs python3 /home/gvoina/scripts/wise_wallet.py balance]

agy: You currently have **0.01 EUR**, **0.00 USD**, and **0.00 RON** in your accounts.

Execute Payment

Developer: Pay 5 EUR to John Doe (DE12345678901234567890) for rent.

agy: ⚠️ This is a financial transfer. Please review the details below:

  • Recipient: John Doe
  • IBAN: DE12345678901234567890
  • Amount: 5.00 EUR
  • Reference: rent

Do you approve executing this transfer?

Developer: Yes, execute.
[agy runs python3 /home/gvoina/scripts/wise_wallet.py payment "John Doe" "DE12345678901234567890" "5.00" "EUR" "rent"]
[The script intercepts Wise’s 2FA challenge, signs it using the private key, and submits the signature]

agy: ✅ Transfer successfully executed! Reference ID: 2548912

7. Conclusion

Integrating the Wise API with Home Assistant and agy creates a secure financial dashboard directly in your workspace. By utilizing a local CLI bridge combined with RSA key-pair signatures, we enable balance checking and money transfers without sacrificing security, turning your coding terminal into a fully capable bank terminal.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.