Skill: cianweeresinghe-sudo/wise-readonly (version 1.0.3)

--- name: wise-readonly description: Read-only Wise API operations for account inspection, FX lookups, recipients, and transfer history. Use when asked to list profiles, balances, recipients, transfers, quotes, delivery estimates, or check current/historical rates without creating transfers, recipients, quotes, or any other mutable resources. --- Read-only Wise API skill for OpenClaw. ## Requirements - `WISE_API_TOKEN` environment variable. - Network access from the runtime host to `https://api.wise.com`. ## Supported Commands - `list_profiles` (PII-redacted by default) - `get_profile --profile-id <id>` (PII-redacted by default) - `list_balances --profile-id <id> [--types STANDARD,SAVINGS]` - `get_balance --profile-id <id> --balance-id <id>` - `get_exchange_rate --source <CUR> --target <CUR>` - `get_exchange_rate_history --source <CUR> --target <CUR> --from <ISO> --to <ISO> [--group day|hour|minute]` - `get_temporary_quote --source <CUR> --target <CUR> [--source-amount <N> | --target-amount <N>]` - `get_quote --quote-id <id>` - `list_recipients --profile-id <id> [--currency <CUR>]` (PII-redacted by default) - `get_recipient --account-id <id>` (PII-redacted by default) - `get_account_requirements --source <CUR> --target <CUR> --source-amount <N>` - `list_transfers --profile-id <id> [--status 
] [--created-date-start <ISO>] [--created-date-end <ISO>] [--limit <N>] [--offset <N>]` (PII-redacted by default) - `get_transfer --transfer-id <id>` (PII-redacted by default) - `get_delivery_estimate --transfer-id <id>` Command runner: - `scripts/wise_readonly.mjs` ## Privacy and Safety - Profile/recipient/transfer responses redact common PII fields by default. - Add `--raw` only when strictly necessary. - This skill performs no write operations. - Disallowed operations include quote creation, transfers, recipient create/update/delete, funding, and cancellation. ## Quick Test ```bash export WISE_API_TOKEN=... node scripts/wise_readonly.mjs list_profiles node scripts/wise_readonly.mjs list_balances --profile-id <id> node scripts/wise_readonly.mjs get_exchange_rate --source GBP --target EUR ``` ## Release Notes ### 1.0.3 - Expanded read-only API coverage based on `wise-mcp` patterns. - Added read-only commands for balances, recipients, transfers, quote lookup, delivery estimate, and historical rates. - Kept strict no-write policy (no create/fund/cancel/delete operations). - Improved metadata clarity for ClawHub and OpenClaw UI. ### 1.0.2 - Added ClawHub secret metadata (`WISE_API_TOKEN` required/primary). - Added explicit UI policy to disable implicit invocation by default. ### 1.0.1 - Added PII redaction defaults and sanitized error handling. - Removed write-capable quote creation path. ### 1.0.0 - Initial read-only profile/balance/rate implementation. ## Source Reference This skill extends read-only coverage using endpoint/tool patterns from: https://github.com/Szotasz/wise-mcp ## Acknowledgements This skill is inspired by wise-mcp by Szotasz: https://github.com/Szotasz/wise-mcp

path: .clawhub/config.toml
content: slug = "wise-readonly"
name = "Wise Read-Only"
version = "1.0.3"
summary = "Read-only Wise API profiles, balances, recipients, transfers, and FX lookups with default PII redaction."

[secrets]
required = ["WISE_API_TOKEN"]
optional = []
primary = "WISE_API_TOKEN"


path: agents/openai.yaml
content: interface:
  display_name: "Wise Read-Only"
  short_description: "Read-only Wise API with PII-safe defaults"
  default_prompt: "Use $wise-readonly to fetch Wise profiles, balances, transfers, recipients, or FX rates safely."

policy:
  allow_implicit_invocation: false


path: scripts/wise_readonly.mjs
content: #!/usr/bin/env node
const BASE = "https://api.wise.com";
const [REDACTED_SECRET];
if (!token) throw new Error("Missing WISE_API_TOKEN");

const [,, cmd, ...args] = process.argv;
const rawOutput = args.includes("--raw");

const getArg = (name) => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};

const getNumArg = (name, { required = false, defaultValue } = {}) => {
  const v = getArg(name);
  if (v === undefined || v === null || v === "") {
    if (required) throw new Error(`Missing ${name}`);
    return defaultValue;
  }
  const n = Number(v);
  if (!Number.isFinite(n)) throw new Error(`Invalid number for ${name}`);
  return n;
};

const SENSITIVE_KEYS = new Set([
  "firstName", "lastName", "dateOfBirth", "phoneNumber", "avatar", "email", "address",
  "firstNameInKana", "lastNameInKana", "accountHolderName", "accountNumber", "iban",
  "bic", "bicSwift", "bankCode", "sortCode", "routingNumber", "abartn", "clabe",
  "ifsc", "cpf", "nationalId", "taxId"
]);

const LOCALIZED_SENSITIVE_KEYS = new Set(["firstName", "lastName", "firstNameInKana", "lastNameInKana"]);

function redactValue(value) {
  if (Array.isArray(value)) return value.map(redactValue);
  if (!value || typeof value !== "object") return value;

  const out = {};
  for (const [k, v] of Object.entries(value)) {
    if (SENSITIVE_KEYS.has(k)) {
      out[k] = "[REDACTED]";
      continue;
    }

    if (k === "localizedInformation" && Array.isArray(v)) {
      out[k] = v.map((entry) => {
        if (entry && typeof entry === "object" && LOCALIZED_SENSITIVE_KEYS.has(String(entry.key))) {
          return { ...entry, value: "[REDACTED]" };
        }
        return redactValue(entry);
      });
      continue;
    }

    out[k] = redactValue(v);
  }
  return out;
}

function errorSummary(status, text) {
  try {
    const parsed = JSON.parse(text);
    const msg = parsed?.message || parsed?.error || "request failed";
    return `Wise API ${status}: ${msg}`;
  } catch {
    return `Wise API ${status}: request failed`;
  }
}

async function apiGet(path, params = {}) {
  const url = new URL(BASE + path);
  Object.entries(params).forEach(([k, v]) => v != null && v !== "" && url.searchParams.set(k, String(v)));

  const r = await fetch(url, {
    method: "GET",
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: "application/json"
    }
  });

  const text = await r.text();
  if (!r.ok) throw new Error(errorSummary(r.status, text));
  return text ? JSON.parse(text) : null;
}

function printJson(value, { redact = false } = {}) {
  const out = redact && !rawOutput ? redactValue(value) : value;
  console.log(JSON.stringify(out));
}

switch (cmd) {
  case "list_profiles": {
    const data = await apiGet("/v1/profiles");
    printJson(data, { redact: true });
    break;
  }

  case "get_profile": {
    const id = getNumArg("--profile-id", { required: true });
    const data = await apiGet(`/v1/profiles/${id}`);
    printJson(data, { redact: true });
    break;
  }

  case "list_balances": {
    const id = getNumArg("--profile-id", { required: true });
    const types = getArg("--types") || "STANDARD";
    const data = await apiGet(`/v4/profiles/${id}/balances`, { types });
    printJson(data);
    break;
  }

  case "get_balance": {
    const profileId = getNumArg("--profile-id", { required: true });
    const balanceId = getNumArg("--balance-id", { required: true });
    const data = await apiGet(`/v4/profiles/${profileId}/balances/${balanceId}`);
    printJson(data);
    break;
  }

  case "get_exchange_rate": {
    const source = (getArg("--source") || "").toUpperCase();
    const target = (getArg("--target") || "").toUpperCase();
    if (!source || !target) throw new Error("Missing --source/--target");
    const data = await apiGet("/v1/rates", { source, target });
    printJson(data);
    break;
  }

  case "get_exchange_rate_history": {
    const source = (getArg("--source") || "").toUpperCase();
    const target = (getArg("--target") || "").toUpperCase();
    const from = getArg("--from") || "";
    const to = getArg("--to") || "";
    const group = getArg("--group") || "day";
    if (!source || !target || !from || !to) throw new Error("Missing --source/--target/--from/--to");
    if (!["day", "hour", "minute"].includes(group)) throw new Error("--group must be one of: day, hour, minute");
    const data = await apiGet("/v1/rates", { source, target, from, to, group });
    printJson(data);
    break;
  }

  case "get_temporary_quote": {
    const source = (getArg("--source") || "").toUpperCase();
    const target = (getArg("--target") || "").toUpperCase();
    const sourceAmount = getArg("--source-amount");
    const targetAmount = getArg("--target-amount");
    if (!source || !target) throw new Error("Missing --source/--target");
    if (!sourceAmount && !targetAmount) throw new Error("Provide --source-amount or --target-amount");
    if (sourceAmount && targetAmount) throw new Error("Provide only one of --source-amount or --target-amount");
    const data = await apiGet("/v1/quotes", {
      source,
      target,
      rateType: "FIXED",
      sourceAmount,
      targetAmount
    });
    printJson(data);
    break;
  }

  case "get_quote": {
    const quoteId = getNumArg("--quote-id", { required: true });
    const data = await apiGet(`/v1/quotes/${quoteId}`);
    printJson(data);
    break;
  }

  case "list_recipients": {
    const profileId = getNumArg("--profile-id", { required: true });
    const currency = (getArg("--currency") || "").toUpperCase() || undefined;
    const data = await apiGet("/v1/accounts", { profile: profileId, currency });
    printJson(data, { redact: true });
    break;
  }

  case "get_recipient": {
    const accountId = getNumArg("--account-id", { required: true });
    const data = await apiGet(`/v1/accounts/${accountId}`);
    printJson(data, { redact: true });
    break;
  }

  case "get_account_requirements": {
    const source = (getArg("--source") || "").toUpperCase();
    const target = (getArg("--target") || "").toUpperCase();
    const sourceAmount = getNumArg("--source-amount", { required: true });
    if (!source || !target) throw new Error("Missing --source/--target");
    const data = await apiGet("/v1/account-requirements", { source, target, sourceAmount });
    printJson(data);
    break;
  }

  case "list_transfers": {
    const profileId = getNumArg("--profile-id", { required: true });
    const status = getArg("--status");
    const createdDateStart = getArg("--created-date-start");
    const createdDateEnd = getArg("--created-date-end");
    const limit = getNumArg("--limit", { defaultValue: 10 });
    const offset = getNumArg("--offset", { defaultValue: 0 });
    const data = await apiGet("/v1/transfers", {
      profile: profileId,
      status,
      createdDateStart,
      createdDateEnd,
      limit,
      offset
    });
    printJson(data, { redact: true });
    break;
  }

  case "get_transfer": {
    const transferId = getNumArg("--transfer-id", { required: true });
    const data = await apiGet(`/v1/transfers/${transferId}`);
    printJson(data, { redact: true });
    break;
  }

  case "get_delivery_estimate": {
    const transferId = getNumArg("--transfer-id", { required: true });
    const data = await apiGet(`/v1/delivery-estimates/${transferId}`);
    printJson(data);
    break;
  }

  default:
    throw new Error(
      "Usage: wise_readonly.mjs <list_profiles|get_profile|list_balances|get_balance|get_exchange_rate|get_exchange_rate_history|get_temporary_quote|get_quote|list_recipients|get_recipient|get_account_requirements|list_transfers|get_transfer|get_delivery_estimate> [--raw] ..."
    );
}


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: clean (LOW)