Quay lại blog
Kỹ thuật

Giving an AI agent a scoped key

An agent that can spend money needs a smaller key than the one your backend uses. The scope split we recommend, the MCP config, and the two limits we insist on.

bởi Ilya Novak·Engineering·Đăng ngày 9 thg 7, 2026·Đọc 7 phút

Since March the same support ticket has arrived in a dozen different wordings: somebody is wiring VirtualSMSNumbers into an agent — a LangGraph node, a desktop MCP config, an in-house tool-calling loop — and wants to know which API key to hand it. For a long time the honest answer was "not the one you already have". This is what we ship instead.

The threat model is not malice

Nobody has yet reported an agent that tried to do something hostile with a key. What they report is an agent being wrong in a loop. In April a customer's onboarding agent spent 41 EUR in ninety minutes buying 380 Indian WhatsApp activations, because its "did the code arrive?" check read messages[0].text and the pool in question returns the code in messages[0].code with an empty text on some carriers. Every iteration concluded the number was dead and bought another one. The agent worked exactly as written.

Three shapes cover almost everything we have seen:

  • Unbounded retries. A purchase is cheap, so nothing in the loop feels expensive until you read the ledger.
  • Silent substitution. The agent asked for a Portuguese number, no Portuguese stock existed, and something in the chain helpfully bought a Nigerian one at four times the price.
  • Over-broad credentials. The key that buys numbers is the same key that can rewrite the webhook endpoint, which means a confused agent can redirect your verification codes to a URL it invented.

One key, three scopes

API keys carry a scope set. There are six scopes and an agent needs at most three of them. numbers:read to look at prices and stock, numbers:write to buy and release, balance:read so it can refuse to start work it cannot pay for. Nothing else.

scopes
numbers:read      list prices, stock, success rate; read an activation
numbers:write     buy a number, request another SMS, cancel, complete
rentals:read      list rentals
rentals:write     create and extend rentals
balance:read      read the account balance
webhooks:write    create, edit and delete webhook endpoints   <- never on an agent key

webhooks:write is the one that matters. An endpoint URL is where your codes go. A key that can change it is a key that can quietly redirect every future verification. There is no agent workflow that needs it; if the agent needs to know about an event, subscribe the endpoint yourself and let the agent read the activation.

The MCP endpoint

/api/mcp speaks JSON-RPC 2.0 over HTTP and advertises protocol revision 2025-06-18. It is the same domain code as the REST API — same billing, same refund rules, same scopes — with five tools in front of it. Point a client at it with an Authorization header and you are done.

mcp.json
{
  "mcpServers": {
    "virtualsmsnumbers": {
      "type": "http",
      "url": "https://virtualsmsnumbers.com/api/mcp",
      "headers": {
        "Authorization": "Bearer vsn_live_a8Kd2rQ4xN7pLmT1yBc"
      }
    }
  }
}
  • vsn_list_prices — live price, stock and success rate for a service, optionally per country.
  • vsn_buy_number — rents a number and debits the balance. Takes max_price_cents.
  • vsn_wait_for_code — blocks server-side until the SMS arrives, up to 280 seconds.
  • vsn_release_number — closes the activation and returns the number to the pool.
  • vsn_get_balance — current balance in cents.

Why waiting is a tool and not a loop

vsn_wait_for_code exists because agents are bad at sleeping. A poll loop turns a two-minute wait into sixty model calls, sixty chances to reconsider the plan, and sixty opportunities to decide that this number is broken and buy a new one. One blocking call is one decision point. The server does the polling internally at two-second intervals and returns the moment a message lands.

tools/call
{
  "jsonrpc": "2.0",
  "id": 4,
  "method": "tools/call",
  "params": {
    "name": "vsn_wait_for_code",
    "arguments": { "activation_id": "1043872915", "timeout_seconds": 180 }
  }
}

{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "content": [{ "type": "text", "text": "code 48219 received after 9s" }],
    "structuredContent": { "code": "48219", "status": "code_received" }
  }
}

If the window closes without a message the tool returns null and the activation is refunded automatically. The agent does not have to know about the refund; there is nothing for it to do.

Limits belong on the key, not in the prompt

"You may spend at most five euros" in a system prompt is a suggestion. The same sentence expressed as a daily cap on the key is a 402. Four controls do the work, and only the last one lives in the call:

ControlWhere it livesWhat the agent sees when it trips
Scope setAPI key403 insufficient_scope — the request never reaches billing
Rate limitAPI key, per minute429 with Retry-After
Daily spend capAPI key402 spend_cap_reached
max_price_centsEach buy call409 no_stock instead of an expensive substitution

max_price_cents is the cheap habit that prevents the second failure shape above. Set it to roughly 1.5x the price you saw in list_prices. A refusal the agent can read and reason about is strictly better than a purchase you find in the ledger three days later.

What we deliberately did not build

  • No key that can top up the balance. Funding is a crypto invoice and a human; an agent cannot create one.
  • No natural-language purchase endpoint. "Get me a number for the messaging app" resolves to one of nine services and the disambiguation failure costs real money. A 422 asking for a slug is the better outcome.
  • No auto-approve mode. If your framework wants confirmation before a spend, keep it. We are not going to sell you a switch that removes it.

Create the key in Settings, API keys, tick three scopes, set a daily cap, and give the agent that. If it goes wrong it goes wrong for at most one day's cap, and you will find the entire history in the ledger with the delivery ids attached.

Các bài viết được đăng bằng tiếng Anh.