Skip to content

Architecture: Providers as Tools. End-to-End

Core insight: A Revolut card payment is the same thing as a Gmail send or a Slack message. It’s a tool call. the LLM says “do this”, and TapPass governs whether it happens, with what constraints, and audits the result.


The Problem With Treating Cards as Special

Section titled “The Problem With Treating Cards as Special”

If we build “Revolut card governance” as a special subsystem, we’ll end up with:

  • A card_spending_policy pipeline step (special-cased)
  • A spending_pact extension (special-cased)
  • A revolut_freeze auto-action (special-cased)
  • A whole “payments” module that lives outside the existing architecture

This doesn’t scale. Next month it’s Stripe issuing, then Wise transfers, then Brex cards. Each one gets its own governance module.

The existing architecture already solves this. A Revolut card payment is just:

tool_name: "revolut_card_payment"
operation: "write"
params: { amount: 4999, currency: "EUR", merchant_mcc: "5734" }
provider: revolut (config/providers/revolut.yaml)
adapter: GenericHTTPAdapter
credential: vault (OAuth2 bearer token)

It flows through the exact same pipeline as gmail_send or slack_send_message. The governance comes from configuring the existing steps, not building new ones.


LLM says: "call send_email(to='alice@acme.com', subject='Q3 Report')"
┌──────────────────────────────────────────────────────────────┐
│ TapPass Pipeline (same for ALL tool calls) │
│ │
│ BEFORE: │
│ validate_input → well-formed? │
│ detect_pii → PII in params? │
│ budget_enforcement → within monthly budget? │
│ session_budget → within session budget? │
│ │
│ CALL: │
│ tool_permissions → is send_email allowed? │
│ tool_constraints → to=*@acme.com? subject≠*confid*? │
│ user_tool_scopes → can this user send email? │
│ require_approval → amount > threshold → human gate │
│ call_tool → vault credential → HTTP call │
│ │
│ AFTER: │
│ scan_output → response safe? │
│ cost_tracking → record cost │
│ audit_trail → hash-chained log │
└──────────────────────────────────────────────────────────────┘

This is identical for a Revolut payment:

LLM says: "call revolut_card_payment(amount=4999, currency='EUR', description='AWS credits')"
┌──────────────────────────────────────────────────────────────┐
│ TapPass Pipeline (same pipeline, same steps) │
│ │
│ BEFORE: │
│ validate_input → well-formed? │
│ detect_pii → PII in params? (card data is PII) │
│ budget_enforcement → within monthly spending budget? │
│ session_budget → within session spending cap? │
│ │
│ CALL: │
│ tool_permissions → is revolut_card_payment allowed? │
│ tool_constraints → amount≤10000? currency∈[EUR,USD]? │
│ merchant_mcc∈[5734,4816]? │
│ user_tool_scopes → can this agent use this card? │
│ require_approval → amount > 50000 → human gate │
│ call_tool → vault credential → Revolut API │
│ │
│ AFTER: │
│ scan_output → response safe? │
│ cost_tracking → record spending │
│ audit_trail → hash-chained log │
└──────────────────────────────────────────────────────────────┘

Nothing new is needed. The governance is configured, not coded.


Looking at the existing code, a provider is defined by three layers:

Layer 1: YAML Spec (config/providers/{name}.yaml)

Section titled “Layer 1: YAML Spec (config/providers/{name}.yaml)”

Declares the API surface. endpoints, auth, operations, constraints, GDPR metadata:

name: revolut
display_name: "Revolut Business"
api_base_url: https://b2b.revolut.com/api/1.0
auth:
type: bearer
header: Authorization
prefix: "Bearer"
oauth:
client_id_env: REVOLUT_CLIENT_ID
client_secret_env: REVOLUT_CLIENT_SECRET
authorize_url: https://business.revolut.com/app-confirm
token_url: https://b2b.revolut.com/api/1.0/auth/token
scopes: [cards:read, cards:write, transactions:read, payments:write]
operations:
# ── Card Operations ────────────────────────────────
- name: create_card
op_group: write
method: POST
endpoint: /cards
description: Issue a virtual card for an agent
constraints:
- name: allowed_labels
type: string_list
param_field: label
description: Card label must match an allowed pattern
- name: freeze_card
op_group: write
method: POST
endpoint: /cards/{card_id}/freeze
description: Freeze a card instantly
- name: unfreeze_card
op_group: write
method: POST
endpoint: /cards/{card_id}/unfreeze
description: Unfreeze a card
- name: update_card_limits
op_group: write
method: PATCH
endpoint: /cards/{card_id}
description: Update spending limits on a card
constraints:
- name: max_single_amount
type: int
param_field: spending_limits.single.amount
description: Maximum per-transaction limit that can be set
# ── Transaction Queries ────────────────────────────
- name: list_transactions
op_group: read
method: GET
endpoint: /transactions
description: List card transactions
- name: get_transaction
op_group: read
method: GET
endpoint: /transactions/{txn_id}
description: Get transaction details
# ── Payments ───────────────────────────────────────
- name: create_payment
op_group: write
method: POST
endpoint: /pay
description: Create a bank transfer
constraints:
- name: max_amount
type: int
param_field: amount
description: Maximum payment amount (in minor units)
- name: allowed_currencies
type: item_list
param_field: currency
description: Allowed payment currencies
- name: allowed_accounts
type: item_list
param_field: receiver.account_id
description: Allowed recipient accounts
adapter_class: tappass.vault.providers.generic.GenericHTTPAdapter
data_region: eu
eu_compliant: true
gdpr_role: processor
gdpr_legal_basis: contract
gdpr_transfer_mechanism: eu_only
dpa_reference: "https://www.revolut.com/legal/business-data-processing"
data_categories: [transactions, card_details, payment_data]
retention_days: 2555 # 7 years (financial records)

Layer 2: Adapter (GenericHTTPAdapter. already exists)

Section titled “Layer 2: Adapter (GenericHTTPAdapter. already exists)”

The GenericHTTPAdapter reads the YAML spec and proxies HTTP calls. No new code needed for Revolut. it’s just REST + Bearer auth, same as Holded, Slack, Jira.

The adapter handles:

  • Path param substitution (/cards/{card_id}/cards/card_abc123)
  • Credential injection from vault (Bearer token in Authorization header)
  • Method routing (GET → query params, POST → JSON body)
  • Error handling and response parsing

Layer 3: Pipeline Config (per-agent governance)

Section titled “Layer 3: Pipeline Config (per-agent governance)”

The CISO configures the pipeline for agents that use Revolut. This is the same pipeline config mechanism used for every other tool:

# Pipeline for "procurement-agent". assigned via admin API
categories:
before:
access_control:
steps:
validate_input: {}
rate_limit: { max_calls: 100, window_seconds: 3600 }
budget_enforcement: { max_usd_monthly: 5000 }
session_budget: { max_cost_usd: 500 }
data_protection:
steps:
detect_pii: {}
detect_secrets: {}
call:
route_and_execute:
steps:
tool_permissions:
permissions:
revolut_create_payment: block # no bank transfers
revolut_create_card: block # no card issuance
revolut_freeze_card: allow # can freeze (safety)
revolut_unfreeze_card: block # can't unfreeze
revolut_list_transactions: allow # can read
revolut_get_transaction: allow # can read
revolut_update_card_limits: block # can't change limits
tool_constraints__revolut_create_payment:
on_detection: block
constraints:
revolut_create_payment:
amount: { max: 50000 } # €500 max
currency: { include: ["EUR", "USD", "GBP"] }
"receiver.account_id": { include: ["acc_supplier1", "acc_supplier2"] }
require_approval:
on_detection: block
actions: [revolut_create_payment]
timeout_seconds: 300
call_tool: { timeout: 30 }
after:
tracking:
steps:
cost_tracking: {}

┌─────────────────────────────────────────────────────────────────────┐
│ TOOL = PROVIDER OPERATION │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ gmail_send │ │ slack_send │ │ revolut_pay │ ... │
│ │ │ │ _message │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ YAML SPEC (config/providers/) │ │
│ │ defines: endpoints, auth, constraints, GDPR │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ ADAPTER (GenericHTTPAdapter) │ │
│ │ handles: HTTP call, credential injection, errors │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ VAULT (credential storage) │ │
│ │ handles: OAuth tokens, API keys, encrypted storage │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ PIPELINE (same steps for all tools) │ │
│ │ tool_permissions → is this tool allowed? │ │
│ │ tool_constraints → are params within bounds? │ │
│ │ user_tool_scopes → can this user call this? │ │
│ │ require_approval → human-in-the-loop? │ │
│ │ budget_enforcement → within spending limit? │ │
│ │ call_tool → execute via adapter │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ CAPABILITY TOKEN │ │
│ │ tools: ["gmail_send"] tools: ["revolut_pay"] │ │
│ │ constraints: {to: ...} constraints: {amount: ...} │ │
│ │ ops: ["write"] ops: ["write"] │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ AUDIT TRAIL (hash-chained, immutable) │ │
│ │ same format for every tool call │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘

What “Governed Spending” Actually Means in This Architecture

Section titled “What “Governed Spending” Actually Means in This Architecture”

There’s no “spending governance module.” There are existing pipeline steps configured for financial tools:

Existing StepConfig for Financial Tools
budget_enforcementmax_usd_monthly: 5000: same step that governs LLM token spend, now also governs card spend
session_budgetmax_cost_usd: 500: cap a single agentic session’s total spend
tool_permissionsrevolut_create_payment: block or allow: per-tool on/off
tool_constraintsamount: {max: 50000}, currency: {include: ["EUR"]}, merchant_mcc: {include: ["5734"]}
user_tool_scopesscope: "own": agent can only use its own card, not others
require_approvalactions: [revolut_create_payment]: human gate for payments
scan_tool_callsAlready blocks dangerous patterns in all tool calls
cost_trackingRecords the payment amount: same cost tracking as LLM calls

The capability token naturally carries the constraints:

{
"tools": ["revolut_create_payment"],
"ops": ["write"],
"cstr": {
"revolut_create_payment": {
"amount": { "type": "range", "max": 50000 },
"currency": { "type": "one_of", "values": ["EUR", "USD"] }
}
},
"cls": "INTERNAL",
"health": 88.9,
"compliance": "regulated"
}

1. One YAML file: config/providers/revolut.yaml

Section titled “1. One YAML file: config/providers/revolut.yaml”

Declares the Revolut Business API surface. ~60 lines. Uses GenericHTTPAdapter: no custom Python code.

2. One tool catalog entry: tappass/tool_catalog.yaml

Section titled “2. One tool catalog entry: tappass/tool_catalog.yaml”
revolut:
display_name: Revolut Business
risk: high
category: payments
default: consent
tools:
- tappass: revolut.create_card
- tappass: revolut.freeze_card
- tappass: revolut.list_transactions
- tappass: revolut.create_payment

3. Pipeline configuration (CISO does this, no code)

Section titled “3. Pipeline configuration (CISO does this, no code)”

The CISO assigns a pipeline to the agent via the dashboard or admin API:

tp.create_pipeline("financial-agent", steps={
"budget_enforcement": {"max_usd_monthly": 5000},
"tool_permissions": {
"permissions": {
"revolut_create_payment": "allow",
"revolut_create_card": "block",
}
},
"tool_constraints__revolut": {
"on_detection": "block",
"constraints": {
"revolut_create_payment": {
"amount": {"max": 50000},
"currency": {"include": ["EUR", "USD"]},
}
}
},
"require_approval": {
"actions": ["revolut_create_payment"],
"timeout_seconds": 300,
},
})
tp.assign("procurement-agent", "financial-agent")

Employee visits /connect/revolut → Revolut consent screen → token stored in vault. Already built.

Terminal window
curl -X PUT localhost:9620/agents/procurement-agent/pact \
-d '{
"purpose": "Purchase software licenses and cloud credits",
"expected_classification": "INTERNAL",
"intended_tools": ["revolut_create_payment", "revolut_list_transactions"],
"intended_operations": ["read", "write"],
"expected_monthly_budget_usd": 5000,
"expected_cost_per_call_usd": 100
}'

Because everything is a tool, you get cross-tool governance for free:

Pact says: "intended_tools: [revolut_list_transactions]" (read-only)
Agent calls: revolut_create_payment
→ Pact violation detected automatically (tool not in intended_tools)
→ Health score drops, drift flagged
Agent spends $200 on OpenAI tokens (LLM calls) +
Agent spends $300 on Revolut payment +
Agent spends $100 on Slack premium API calls
= $600 total. all tracked by the same budget_enforcement step
Agent usually: 90% revolut_list_transactions, 10% revolut_create_payment
Agent today: 20% list, 80% create_payment
→ Tool usage drift: MAJOR (Jaccard distance)
→ Alert fired to CISO
Trust score drops to 400 (agent caused incidents)
→ OPA policy: trust < 600 → tool_constraints.amount.max = 5000 (€50)
→ Applies to revolut_create_payment AND any other financial tool
→ No special code. OPA evaluates trust for every tool call
require_approval:
actions:
- revolut_create_payment # payment > threshold → approve
- gmail_send # email to external → approve
- holded_create_invoice # new invoice → approve
- jira_delete_issue # destructive action → approve

Same step, same flow, same Slack webhook, same admin API to approve/deny.


Reactive Governance: Webhooks as Inbound Tool Calls

Section titled “Reactive Governance: Webhooks as Inbound Tool Calls”

Revolut sends transaction webhooks. These are the reverse of a tool call. the external service tells TapPass something happened. But the pipeline still governs the response:

Revolut webhook: TransactionCreated { card_id, amount: 25000, mcc: 7995 }
TapPass receives webhook
├─ Record in audit trail (hash-chained)
├─ Check against pact: MCC 7995 (gambling) not in allowed_mccs → violation
├─ Check against budget: $250 within limits but unexpected category
├─ Drift detection: new MCC → MAJOR drift signal
├─ Auto-response (governed by OPA policy):
│ └─ drift_level == "major" → freeze_card (call back to Revolut API)
└─ Alert CISO via webhook

This fits the existing webhook alerting system (observability/alerting.py). The only new piece is a webhook receiver endpoint. a standard FastAPI route that feeds events into the audit trail and optionally triggers tool calls (like freeze_card) through the normal pipeline.

@router.post("/webhooks/revolut")
async def revolut_webhook(request: Request):
payload = await request.json()
event_type = payload.get("event")
# Record in audit trail
await app.audit.record(AuditEvent(
event_type=f"revolut_{event_type}",
resource="revolut",
details=payload,
))
# Check policy. should we auto-respond?
if event_type == "TransactionCreated":
txn = payload.get("data", {})
# Feed through pipeline as a "reactive tool call"
# The pipeline decides whether to freeze, alert, or ignore
...

This architecture means adding Stripe Issuing, Wise, Brex, or any other financial service is:

  1. One YAML file (config/providers/stripe_issuing.yaml)
  2. One catalog entry (5 lines in tool_catalog.yaml)
  3. Pipeline config (CISO does this, no code)
config/providers/wise.yaml
name: wise
display_name: "Wise Business"
api_base_url: https://api.transferwise.com/v3
auth:
type: bearer
header: Authorization
prefix: "Bearer"
operations:
- name: create_transfer
op_group: write
method: POST
endpoint: /profiles/{profile_id}/transfers
constraints:
- name: max_amount
type: int
param_field: targetAmount
- name: allowed_currencies
type: item_list
param_field: targetCurrency
adapter_class: tappass.vault.providers.generic.GenericHTTPAdapter

Same pipeline governs it. Same capability tokens authorize it. Same audit trail logs it. Same dashboard shows it.


Don’t build a payments module. Configure the existing tool governance for payment tools.

WhatHowNew Code?
Revolut API accessYAML provider spec + GenericHTTPAdapterOne YAML file
Spending limitstool_constraints step (amount: max)No: config only
MCC restrictionstool_constraints step (mcc: include)No: config only
Per-agent card accesstool_permissions + user_tool_scopesNo: config only
Human approvalrequire_approval stepNo: config only
Budget capsbudget_enforcement + session_budgetNo: config only
Credential storageVault + OAuth ConnectNo: existing flow
Capability tokensToken minting with constraintsNo: automatic
Audit trailSame hash-chained auditNo: automatic
Behavioral pactsSame pact systemNo: config only
Trust-based limitsOPA policy + trust scoringNo: OPA config
Drift detectionSame drift engineNo: automatic
Webhook reactionsOne webhook endpoint~50 lines
Dashboard visibilityTools already show in dashboardNo: automatic

Total new code: One YAML file + one webhook endpoint. Everything else is configuration of existing systems.

The architecture is: providers are tools, tools are governed by the pipeline, the pipeline is configured by the CISO. Adding a new financial provider is the same effort as adding a new SaaS integration: because it is the same thing.