docs
fren get sandbox key

start

examples

Four runnable examples against the live sandbox: an MCP client setup, a TypeScript agent paying an API bill, a Python agent buying ad credits with human approval, and an admin script that sets a mandate.

A public repository nicolasfinance/nostro-examples is coming soon. Until then, the full code of each example is on this page. Every example reads its key from an environment variable: get one at nostro.ag/sandbox.

01 · MCP client config + prompts

Claude Code, in one command:

shell
export NOSTRO_AGENT_KEY=nst_...
claude mcp add --transport http nostro https://mcp.nostro.ag/mcp \
  --header "Authorization: Bearer $NOSTRO_AGENT_KEY"

Then ask, in order:

  1. What is my Nostro budget today? calls get_wallet.
  2. Pay 12 € to Linear for our November seat (MCC 5734). settles inside the mandate.
  3. Pay 900 € to Linear. is denied: above the 500 € per-payment cap.
  4. Pay 300 € to Google Ads for ad credits. returns pending_approval (human above 250 €).
  5. Show my last five operations. calls list_my_activity.

To check the server without a client, test.sh sends the same JSON-RPC calls (curl and jq):

test.sh
#!/usr/bin/env bash
# Smoke test for the Nostro MCP server, the same JSON-RPC calls an MCP client (Claude Code, Cursor...) makes.
#   NOSTRO_AGENT_KEY=nst_... ./test.sh
# Needs curl and jq.
set -euo pipefail
: "${NOSTRO_AGENT_KEY:?Set NOSTRO_AGENT_KEY (nst_...). Get one free at https://nostro.ag/sandbox}"
URL="${NOSTRO_MCP_URL:-https://mcp.nostro.ag/mcp}"

rpc() { # method params
  curl -sS "$URL" \
    -H "Authorization: Bearer $NOSTRO_AGENT_KEY" \
    -H "Content-Type: application/json" \
    -H "Accept: application/json, text/event-stream" \
    -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$1\",\"params\":$2}"
}
tool() { rpc tools/call "{\"name\":\"$1\",\"arguments\":$2}" | jq -r '.result.content[0].text' | jq -c "${3:-.}"; }

echo "# initialize"
rpc initialize '{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test.sh","version":"1"}}' | jq -c '.result.serverInfo'
echo "# tools/list"
rpc tools/list '{}' | jq -c '[.result.tools[].name]'
echo "# get_wallet"
tool get_wallet '{}' '{agent:.agent.name, budget}'
echo "# pay 12 EUR to Linear"
tool pay '{"amount":12,"merchant":"Linear","mcc":"5734","description":"Linear seat, November"}'
echo "# pay 900 EUR to Linear (over the per-payment cap)"
tool pay '{"amount":900,"merchant":"Linear","mcc":"5734"}'
verified output, live sandbox, 2026-11-05
# initialize
{"name":"nostro","version":"0.1.0"}
# tools/list
["get_wallet","pay","send_transfer","list_my_activity","get_request_status"]
# get_wallet
{"agent":"demo-agent","budget":{"currency":"EUR","perTxMax":500,"approvalAbove":250,"dailyRemaining":1615,"monthlyRemaining":4615}}
# pay 12 EUR to Linear
{"status":"paid","transactionId":"vtx_cc7f5f80e433","amount":12,"merchant":"Linear"}
# pay 900 EUR to Linear (over the per-payment cap)
{"status":"denied","reason":"Dépasse le plafond par transaction (500 €)"}

02 · TypeScript agent pays an API bill

Uses the official @modelcontextprotocol/sdk client over streamable HTTP. Reads the wallet, checks the bill fits the mandate, pays, prints its activity.

shell
npm install @modelcontextprotocol/sdk tsx
export NOSTRO_AGENT_KEY=nst_...
npx tsx agent.ts
agent.ts
// A TypeScript agent that pays an API bill through Nostro, over MCP, inside its mandate.
//
//   NOSTRO_AGENT_KEY=nst_... npx tsx agent.ts
//
// Flow: list the tools, read the wallet, check the bill fits the budget, pay, then read the activity.
// The policy is enforced server side: the agent cannot spend more than its mandate, whatever this code does.
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const KEY = process.env.NOSTRO_AGENT_KEY;
const URL_ = process.env.NOSTRO_MCP_URL ?? "https://mcp.nostro.ag/mcp";
if (!KEY?.startsWith("nst_")) {
  console.error("Set NOSTRO_AGENT_KEY to an agent key (nst_...). Get one free at https://nostro.ag/sandbox");
  process.exit(1);
}

// The bill this agent has to settle. 5734 = computer software stores (MCC).
const bill = { amount: Number(process.env.BILL_AMOUNT ?? 42.5), merchant: "OpenAI API", mcc: "5734", description: "Monthly API usage, invoice 2026-10" };

const client = new Client({ name: "billing-agent", version: "1.0.0" });
await client.connect(new StreamableHTTPClientTransport(new URL(URL_), {
  requestInit: { headers: { Authorization: `Bearer ${KEY}` } },
}));

// MCP tool results come back as a JSON string in the first text block.
async function call<T = any>(name: string, args: Record<string, unknown> = {}): Promise<T> {
  const r: any = await client.callTool({ name, arguments: args });
  const data = JSON.parse(r.content?.[0]?.text ?? "{}");
  if (r.isError) throw new Error(`${name}: ${data.error ?? "error"}`);
  return data as T;
}

const eur = (n: unknown) => (n == null ? "-" : `€${Number(n).toFixed(2)}`);

const { tools } = await client.listTools();
console.log("tools:", tools.map((t) => t.name).join(", "));

const wallet = await call("get_wallet");
console.log(`wallet: ${wallet.agent.name} · card ${wallet.card.maskedNumber} · ` +
  `per payment ≤ ${eur(wallet.budget.perTxMax)} · today ${eur(wallet.budget.dailyRemaining)} left · ` +
  `human approval above ${eur(wallet.budget.approvalAbove)}`);

if (bill.amount > wallet.budget.perTxMax || bill.amount > wallet.budget.dailyRemaining) {
  console.log(`bill of ${eur(bill.amount)} does not fit the mandate, escalating to a human instead of paying.`);
  await client.close();
  process.exit(0);
}

const res = await call("pay", bill);
switch (res.status) {
  case "paid":
    console.log(`paid ${eur(res.amount)} to ${res.merchant} · transaction ${res.transactionId}`);
    break;
  case "pending_approval":
    console.log(`waiting for a human: request ${res.requestId} (${res.reason})`);
    break;
  default:
    console.log(`not paid: ${res.status} · ${res.reason}`);
}

const activity = await call("list_my_activity", { limit: 3 });
console.log("last activity:");
for (const e of activity) console.log(`  ${e.created_at}  ${e.type.padEnd(13)} ${eur(e.amount).padStart(9)}  ${(e.counterparty ?? "").padEnd(12)} ${e.decision}`);

await client.close();
verified output, live sandbox, 2026-11-05
tools: get_wallet, pay, send_transfer, list_my_activity, get_request_status
wallet: demo-agent · card 4970 •••• •••• 2007 · per payment ≤ €500.00 · today €1603.00 left · human approval above €250.00
paid €42.50 to OpenAI API · transaction vtx_f7f8cd0c97a4
last activity:
  2026-11-05T08:19:46.251Z  card_payment     €42.50  OpenAI API   executed
  2026-11-05T08:19:38.125Z  card_payment    €900.00  Linear       denied
  2026-11-05T08:19:36.873Z  card_payment     €12.00  Linear       executed

03 · Python agent buys ad credits

For agents without MCP. Above the approval threshold the API answers pending_approval; the agent polls the request until a human decides, and never retries or splits the amount.

shell
pip install requests
export NOSTRO_AGENT_KEY=nst_...
python buy_ad_credits.py 300
buy_ad_credits.py
"""Buy ad credits through Nostro's REST API (/v1/agent) and handle human approval.

    pip install requests
    NOSTRO_AGENT_KEY=nst_... python buy_ad_credits.py 300

An ad-buying agent tops up its ad account. Small top-ups settle at once; anything above the
agent's approval threshold comes back as `pending_approval`, and the agent waits for a human
(approve it from the admin console, see ../04-admin-mandate) instead of retrying or splitting
the amount.
"""
import os
import sys
import time

import requests

BASE = os.environ.get("NOSTRO_API_URL", "https://mcp.nostro.ag")
KEY = os.environ.get("NOSTRO_AGENT_KEY", "")
POLL_SECONDS = int(os.environ.get("POLL_SECONDS", "5"))
TIMEOUT_SECONDS = int(os.environ.get("TIMEOUT_SECONDS", "600"))

if not KEY.startswith("nst_"):
    sys.exit("Set NOSTRO_AGENT_KEY to an agent key (nst_...). Get one free at https://nostro.ag/sandbox")

session = requests.Session()
session.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})


def api(method: str, path: str, **kw) -> dict:
    r = session.request(method, f"{BASE}/v1/agent{path}", timeout=30, **kw)
    body = r.json()
    if r.status_code >= 400:
        raise RuntimeError(f"{r.status_code} {body.get('error', body)}")
    return body


def main(amount: float) -> int:
    wallet = api("GET", "/wallet")
    budget = wallet["budget"]
    print(f"agent {wallet['agent']['name']} · per payment ≤ €{budget['perTxMax']:.2f} · "
          f"approval above €{budget['approvalAbove']:.2f} · month left €{budget['monthlyRemaining']:.2f}")

    # 7311 = advertising services (MCC)
    res = api("POST", "/pay", json={
        "amount": amount,
        "merchant": "Google Ads",
        "mcc": "7311",
        "description": "Ad credits for the November search campaign",
    })
    print(f"pay €{amount:.2f} to Google Ads -> {res['status']}" + (f" ({res.get('reason')})" if res.get("reason") else ""))

    if res["status"] == "paid":
        print(f"settled · transaction {res['transactionId']}")
        return 0
    if res["status"] != "pending_approval":
        print("refused by the mandate, not retrying")
        return 1

    req_id = res["requestId"]
    print(f"waiting for a human to approve {req_id} (polling every {POLL_SECONDS}s)")
    deadline = time.time() + TIMEOUT_SECONDS
    while time.time() < deadline:
        time.sleep(POLL_SECONDS)
        r = api("GET", f"/requests/{req_id}")
        if r["status"] in ("pending", "deciding"):
            continue
        result = r.get("result") or {}
        print(f"request {req_id} -> {r['status']}" + (f" · transaction {result.get('transactionId')}" if result.get("transactionId") else ""))
        return 0 if r["status"] == "executed" else 1
    print("no decision before timeout, leaving the request pending")
    return 2


if __name__ == "__main__":
    sys.exit(main(float(sys.argv[1]) if len(sys.argv) > 1 else 300.0))
verified output, live sandbox, 2026-11-05
agent demo-agent · per payment ≤ €500.00 · approval above €250.00 · month left €4915.00
pay €300.00 to Google Ads -> pending_approval (Au-dessus du seuil d'approbation (250 €))
waiting for a human to approve req_fca22be1 (polling every 3s)
request req_fca22be1 -> executed · transaction vtx_db6da67fbbbc

04 · admin: mandate, funding, approval

The company side, with the admin key: create an ads agent restricted to MCC 7311, fund the account, let the agent spend, approve what waits, print the audit trail. Node 18+, no dependencies. node admin.mjs approve only approves pending requests, which pairs with example 03.

shell
export NOSTRO_ADMIN_KEY=nsa_...
node admin.mjs
admin.mjs
// Admin script: create an agent with a spending mandate, fund the account, approve what waits for a human.
//
//   NOSTRO_ADMIN_KEY=nsa_... node admin.mjs            # setup + demo + approve
//   NOSTRO_ADMIN_KEY=nsa_... node admin.mjs approve    # only approve pending requests (pairs with ../03)
//
// Node 18+, no dependencies. Uses the admin REST API (/api). The same actions exist as MCP tools
// (create_agent, fund_account, list_pending_approvals, approve_request) for an admin MCP client.
const BASE = process.env.NOSTRO_API_URL ?? "https://mcp.nostro.ag";
const ADMIN = process.env.NOSTRO_ADMIN_KEY ?? "";
if (!ADMIN.startsWith("nsa_")) {
  console.error("Set NOSTRO_ADMIN_KEY to a workspace admin key (nsa_...). Get one free at https://nostro.ag/sandbox");
  process.exit(1);
}

async function req(path, { method = "GET", body, key = ADMIN } = {}) {
  const r = await fetch(BASE + path, {
    method,
    headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  const data = await r.json();
  if (!r.ok) throw new Error(`${method} ${path} -> ${r.status} ${data.error ?? ""}`);
  return data;
}
const eur = (n) => `€${Number(n).toFixed(2)}`;

async function approveAll() {
  const pending = await req("/api/requests?status=pending");
  if (!pending.length) console.log("no pending approvals");
  for (const p of pending) {
    const what = p.payload?.merchant ?? p.payload?.beneficiaryName ?? p.kind;
    const done = await req(`/api/requests/${p.id}/approve`, { method: "POST" });
    console.log(`approved ${p.id} · ${p.agent_name} · ${eur(p.amount)} to ${what} -> ${done.status}`);
  }
}

if (process.argv[2] === "approve") {
  await approveAll();
  process.exit(0);
}

// 1. A mandate for a paid-media agent: ads only, €300 per payment, human above €150.
const { agent, apiKey } = await req("/api/agents", {
  method: "POST",
  body: {
    name: `ads-buyer-${Date.now().toString(36).slice(-4)}`,
    purpose: "Tops up ad accounts for the growth team",
    policy: { perTxMax: 300, dailyMax: 600, monthlyMax: 3000, approvalAbove: 150, allowedMccs: ["7311"] },
  },
});
const p = agent.policy;
console.log(`created ${agent.id} (${agent.name}) · card ${agent.card.maskedNumber}`);
console.log(`  mandate: ≤ ${eur(p.perTxMax)}/payment · ${eur(p.dailyMax)}/day · ${eur(p.monthlyMax)}/month · human above ${eur(p.approvalAbove)} · mcc ${p.allowedMccs.join(",")}`);
console.log(`  agent key (shown once): ${apiKey.slice(0, 8)}…`);

// 2. Fund the account (sandbox: fake euros).
await req("/api/fund", { method: "POST", body: { amount: 2000 } });
const ov = await req("/api/overview");
console.log(`funded ${eur(2000)} · balance ${eur(ov.account.available.value)} ${ov.account.available.currency}`);

// 3. The agent spends with its own key: one inside the mandate, one that needs a human, one off-mandate.
for (const t of [
  { amount: 80, merchant: "Meta Ads", mcc: "7311" },
  { amount: 240, merchant: "Google Ads", mcc: "7311" },
  { amount: 45, merchant: "Steam", mcc: "5816" },
]) {
  const r = await req("/v1/agent/pay", { method: "POST", key: apiKey, body: { ...t, description: "demo" } });
  console.log(`agent pays ${eur(t.amount)} to ${t.merchant} -> ${r.status}${r.reason ? ` (${r.reason})` : ""}`);
}

// 4. A human approves what is waiting.
await approveAll();

// 5. Audit trail for this agent.
const log = await req(`/api/events?agentId=${agent.id}&limit=10`);
console.log("audit:");
for (const e of log.reverse()) console.log(`  ${e.type.padEnd(14)} ${(e.amount == null ? "-" : eur(e.amount)).padStart(8)}  ${(e.counterparty ?? "").padEnd(11)} ${e.decision}`);
verified output, live sandbox, 2026-11-05
created agt_4db1278d (ads-buyer-xsg1) · card 4970 •••• •••• 7424
  mandate: ≤ €300.00/payment · €600.00/day · €3000.00/month · human above €150.00 · mcc 7311
  agent key (shown once): nst_NtIZ…
funded €2000.00 · balance €11915.00 EUR
agent pays €80.00 to Meta Ads -> paid
agent pays €240.00 to Google Ads -> pending_approval (Au-dessus du seuil d'approbation (150 €))
agent pays €45.00 to Steam -> denied (Catégorie marchand 5816 non autorisée)
approved req_184e730d · ads-buyer-xsg1 · €240.00 to Google Ads -> executed
audit:
  agent_created         -              executed
  card_payment     €80.00  Meta Ads    executed
  card_payment    €240.00  Google Ads  pending_approval
  card_payment     €45.00  Steam       denied
  card_payment    €240.00  Google Ads  executed