Skip to content

Lynx Capital: Autonomous Payouts

The Lynx Capital example is a complete Python application at examples/lynxCapital/ that runs a realistic weekly payout cycle: 4,200 invoices, 5 geographic regions, approximately $8.5M USD in payments, multi-currency settlement, regional tax compliance, vendor contract verification, and threshold-based routing across banking rails.

The application is not a tutorial scaffold. It is a production-pattern reference that demonstrates how Caracal’s authorization model maps to real enterprise workloads — specifically the patterns of hierarchical agent spawning, delegation scope enforcement, ephemeral single-operation agents, and multi-protocol transport across REST, gRPC, MCP, and SSE endpoints.


Delegation hierarchy. The Finance Control agent holds global authority and delegates regional authority to Regional Orchestrator agents. Each Regional Orchestrator further delegates domain-scoped authority to agents that perform specific tasks (invoice extraction, ledger matching, compliance screening, payment execution). The hierarchy mirrors the organizational authority model that Caracal is designed to enforce.

Ephemeral payment agents. The Payment Execution agent is spawned for a single transaction: one vendor, one amount, one payment rail, one time window. It terminates immediately on completion. This is the pattern for operations that should have tightly bounded, non-transferable authority.

Exception agents. When anomalies are detected, an Exception agent is spawned with investigative-only scope. It can read vendor and compliance data but is explicitly not permitted to execute payments. The scope boundary is enforced at the authorization layer.

Multi-protocol transport. Within the same orchestration run, agents call external services over REST (Mercury Bank, Wise, NetSuite), gRPC (Treasury Operations), MCP (Vendor Portal), and SSE streaming (FX rates). All calls carry the agent’s identity and are traceable through the full event stream.

Full traceability. Every action — spawn, delegation, tool call, service call, result — emits a typed event. The complete lineage of who spawned whom, what tools they called, and what the external services returned is reconstructable from the event log.


examples/lynxCapital/
├── app/
│ ├── main.py # FastAPI entry point; lifespan setup
│ ├── config.py # Loads config/company.yaml
│ ├── agents/
│ │ ├── roles.py # 9 agent role definitions with scope templates
│ │ ├── tools.py # 40+ tool functions (one per provider action)
│ │ └── runner.py # AgentHandle, AgentRunner — session lifecycle
│ ├── orchestration/
│ │ ├── swarm.py # LLM orchestration loop (Finance Control + Regional)
│ │ └── topology.py # Agent topology graph for visualization
│ ├── services/
│ │ ├── registry.py # Central dispatch: call(service_id, action, payload)
│ │ └── transport/
│ │ ├── rest.py # HTTP via httpx; retry, circuit breaker
│ │ ├── grpc_client.py # gRPC unary calls with metadata auth
│ │ ├── mcp.py # MCP tool invocation for vendor-portal
│ │ └── sse.py # SSE streaming for FX rates
│ ├── events/
│ │ ├── bus.py # Event pub/sub for SSE streaming to UI
│ │ └── types.py # Typed event factories
│ └── api/
│ ├── run.py # POST /api/start, GET /api/{id}/events
│ └── ...
├── config/
│ └── company.yaml # Regions, providers, agent layers, system prompts
├── _mock/
│ ├── docker-compose.yml # 5 mock service containers
│ ├── rest/ # Single FastAPI app fronting 13 REST providers
│ ├── grpc/ # gRPC servers: treasury-ops (50051), compliance (50052)
│ ├── mcp/ # MCP vendor portal (7800)
│ └── streaming/ # SSE FX rates stream (8810)
├── pyproject.toml
└── .env.example

  • Python 3.11+
  • Docker and Docker Compose
  • An OpenAI API key (OPENAI_API_KEY) — the application fails at startup if this is absent
Terminal window
cd examples/lynxCapital
python -m venv .venv
source .venv/bin/activate
pip install -e .

All 14 external providers run as deterministic mock services in Docker. Start them before the application:

Terminal window
docker compose -f _mock/docker-compose.yml up -d --build

This starts:

ContainerPortProvides
rest880013 REST providers on a single port, routed by path
fx-stream8810FX rate stream over SSE
treasury-grpc50051Treasury operations over gRPC
compliance-grpc50052Compliance streaming over gRPC
vendor-mcp7800Vendor portal over MCP
Terminal window
cp .env.example .env

Edit .env and set OPENAI_API_KEY. All other variables have defaults that point to the mock containers:

Terminal window
OPENAI_API_KEY=sk-...
# Mock service URLs (defaults shown — no change needed for local dev)
LYNX_MERCURY_URL=http://127.0.0.1:8800
LYNX_WISE_URL=http://127.0.0.1:8800
LYNX_FX_STREAM_URL=http://127.0.0.1:8810/v1/stream
LYNX_TREASURY_GRPC=127.0.0.1:50051
LYNX_MCP_HOST=127.0.0.1
LYNX_MCP_PORT=7800
# Dev credentials (pre-configured for mock services)
LYNX_MERCURY_KEY=dev-mercury-bank-key
LYNX_WISE_KEY=dev-wise-payouts-key
# ... (see .env.example for the full list)

LYNX_MOCK_FAST=1 speeds up mock response latencies during development.

Terminal window
uvicorn app.main:app --reload --port 8000

Open http://localhost:8000. The landing page shows the scenario summary. Navigate to /demo to interact with the agent swarm.


The swarm has 9 agent layers. Each layer is defined in app/agents/roles.py with a role name, scope template, and permitted tools.

Finance Control global scope
└── Regional Orchestrator region:{region}
├── Invoice Intake invoice-batch:{region}
├── Ledger Match ledger-batch:{region}
├── Policy Check policy-batch:{region}
├── Route Optimization route-batch:{region}
├── Payment Execution * payment:{vendor_id}:{invoice_id}
├── Audit audit:{region}
└── Exception * exception:{vendor_id}

* — Ephemeral agents. They are spawned for a single operation and terminated on completion.

Scope templates determine tool authority. The Payment Execution agent’s scope is payment:{vendor_id}:{invoice_id} — scoped to exactly one vendor and one invoice. It is the only agent permitted to call submit_payment, submit_payout, and create_outbound_payment. No other agent has these tools. The Exception agent’s scope explicitly excludes all payment tools; it can only call check_vendor and get_vendor_profile.


14 external providers are integrated, covering banking, ERP, compliance, document processing, FX, gRPC treasury operations, MCP vendor management, and regulatory filing.

ProviderCategoryProtocolActions
mercury-bankBankingRESTget_account_balance, submit_payment
wise-payoutsPayoutsRESTget_quote, submit_payout
stripe-treasuryBankingSDK (REST)get_financial_account, create_outbound_payment
netsuiteERPREST + async jobget_vendor_record, match_invoice
sap-erpERPREST + async jobget_vendor_record, match_invoice
quickbooksERPRESTget_vendor, match_bill
compliance-nexusComplianceRESTcheck_vendor, check_transaction, get_withholding_rate, validate_tax_id
ocr-visionDocumentREST + async jobextract_invoice
vendor-portalVendorMCPget_vendor_profile, get_contract_terms, register_vendor
tax-rulesTaxSDKget_withholding_rate, validate_tax_id
fx-ratesFXREST + SSE streamget_rate, live rate stream
treasury-opsTreasurygRPCget_cash_position, forecast_liquidity, place_fx_hedge, transfer_funds
close-engineAccountingREST + async jobpost_journal_entry, reconcile_account, close_period
regulatory-filingsRegulatoryREST + async jobaml_monitor_transaction, sanctions_screen_batch, prepare_regulatory_filing

All providers route through the service registry at app/services/registry.py:

result = registry.call("mercury-bank", "submit_payment", {
"vendor_id": "us-axiom-cloud",
"amount": 50000,
"currency": "USD",
"rail": "ACH",
"reference": "INV001",
})

The registry dispatches to the appropriate transport client (REST, gRPC, MCP, or SDK) based on the service ID.


Every external provider call goes to a deterministic mock. Mock responses are defined in cases.json files keyed by a primary identifier (usually vendor_id):

{
"service": "mercury-bank",
"actions": {
"get_account_balance": {
"match_key": "vendor_id",
"cases": {
"us-axiom-cloud": {
"account_id": "acct-MCY-AXM-001",
"balance": 487500.00,
"currency": "USD",
"status": "active"
},
"default": { ... }
}
}
}
}

The same input always produces the same output. This makes the example fully reproducible and testable without real credentials.

Payment submission mocks also emit webhooks with configurable delays, simulating the asynchronous settlement flow of real banking APIs:

# In _mock/rest/routers/mercury_bank.py
deliver("mercury-bank", "transaction.posted", {...}, delay_s=0.5)
deliver("mercury-bank", "transaction.settled", {...}, delay_s=1.5)

The application handles these callbacks at its webhook endpoint, updating payment status in the event stream.


The following traces a complete execution from user input to completion.

POST /api/start
Content-Type: application/json
{"prompt": "Process US payouts for this week"}

Response:

{"runId": "019500a2-..."}

The application starts a background task and returns immediately. The client connects to the SSE stream to receive real-time events:

GET /api/019500a2-.../events
Accept: text/event-stream

The swarm runner spawns the Finance Control agent with global scope. It receives the user prompt and builds a dispatch plan:

event: agent_spawn
data: {"agent_id": "fc-001", "role": "finance-control", "scope": "global", "parent_id": null}
event: agent_start
data: {"agent_id": "fc-001"}
event: tool_call
data: {"agent_id": "fc-001", "tool": "dispatch_region", "args": {"region": "US", "focus": "Process payout cycle"}}
event: tool_result
data: {"agent_id": "fc-001", "tool": "dispatch_region", "result": {"job_id": "job-us-001"}}

Finance Control calls await_jobs(["job-us-001"]) and waits.

A Regional Orchestrator agent is spawned as a child of Finance Control with region:US scope:

event: agent_spawn
data: {"agent_id": "ro-us-001", "role": "regional-orchestrator", "scope": "region:US", "parent_id": "fc-001"}
event: delegation
data: {"from": "fc-001", "to": "ro-us-001", "scope": "region:US"}
event: agent_start
data: {"agent_id": "ro-us-001"}

The Regional Orchestrator works through its invoice batch. For each invoice, it spawns the appropriate domain agent, awaits its result, and moves to the next step.

event: agent_spawn
data: {"agent_id": "ii-001", "role": "invoice-intake", "scope": "invoice-batch:US", "parent_id": "ro-us-001"}
event: tool_call
data: {"agent_id": "ii-001", "tool": "extract_invoice", "args": {"invoice_id": "INV001", "doc_id": "doc-INV001"}}
event: service_call
data: {"agent_id": "ii-001", "service": "ocr-vision", "action": "extract_invoice", "protocol": "REST"}
event: service_result
data: {"agent_id": "ii-001", "service": "ocr-vision", "status": 200, "result": {"vendor": "Axiom Cloud", "amount": 50000, "currency": "USD"}}
event: tool_result
data: {"agent_id": "ii-001", "tool": "extract_invoice", "result": {...}}
event: agent_terminate
data: {"agent_id": "ii-001", "status": "completed"}

The Invoice Intake agent terminates immediately after returning its result. Its authority ends when the tool call completes.

event: agent_spawn
data: {"agent_id": "pc-001", "role": "policy-check", "scope": "policy-batch:US", "parent_id": "ro-us-001"}
event: service_call
data: {"service": "compliance-nexus", "action": "check_vendor", "args": {"vendor_id": "us-axiom-cloud"}}
event: service_result
data: {"status": 200, "result": {"vendor_id": "us-axiom-cloud", "status": "cleared", "risk_score": 0.15}}
event: agent_terminate
data: {"agent_id": "pc-001", "status": "completed"}

The Payment Execution agent is spawned with scope payment:us-axiom-cloud:INV001. This is the narrowest possible authority: one vendor, one invoice.

event: agent_spawn
data: {"agent_id": "pe-001", "role": "payment-execution", "scope": "payment:us-axiom-cloud:INV001", "parent_id": "ro-us-001"}
event: service_call
data: {"service": "mercury-bank", "action": "submit_payment", "args": {"vendor_id": "us-axiom-cloud", "amount": 50000, "currency": "USD", "rail": "ACH", "reference": "INV001"}}
event: service_result
data: {"status": 200, "result": {"tx_id": "TX-MCY-AXM-001", "status": "submitted"}}
event: agent_terminate
data: {"agent_id": "pe-001", "status": "completed"}

Mercury Bank enqueues two webhook callbacks — transaction.posted (0.5 seconds) and transaction.settled (1.5 seconds) — which arrive asynchronously and update the event stream.

When the Regional Orchestrator finishes all invoices, its job completes and Finance Control’s await_jobs resolves:

event: agent_terminate
data: {"agent_id": "ro-us-001", "status": "completed"}
event: tool_result
data: {"agent_id": "fc-001", "tool": "await_jobs", "result": {"status": "completed", "summary": {...}}}
event: agent_terminate
data: {"agent_id": "fc-001", "status": "completed"}
event: run_end
data: {"run_id": "019500a2-...", "status": "completed"}

The same orchestration run calls providers over four different protocols. The transport client is selected by the service registry based on the service ID:

REST (Mercury Bank, Wise, NetSuite, etc.):

app/services/transport/rest.py
client = RestClient(base_url="http://127.0.0.1:8800", auth=AuthSpec("Bearer", key))
response = await client.post("/v1/payments", payload)

Includes retry logic with exponential backoff, circuit breaker, and idempotency key support for write operations.

gRPC (Treasury Operations):

app/services/transport/grpc_client.py
client = GrpcClient("127.0.0.1:50051", metadata={"authorization": f"Bearer {token}"})
response = await client.call("GetCashPosition", request)

MCP (Vendor Portal):

app/services/transport/mcp.py
client = McpClient(host="127.0.0.1", port=7800)
result = await client.call_tool("vendor.get_profile", {"vendor_id": "us-axiom-cloud"})

SSE streaming (FX rates):

app/services/transport/sse.py
async for rate_update in sse_stream("http://127.0.0.1:8810/v1/stream"):
update_cached_rate(rate_update["pair"], rate_update["rate"])

All events are available over SSE at /api/{run_id}/events. The event types in emission order:

Event typeDescription
agent_spawnAgent created; includes role, scope, parent ID
delegationAuthority delegated from parent to child
agent_startAgent begins executing
tool_callAgent invokes a tool; includes tool name and args
service_callTool dispatches to an external service; includes protocol
service_resultExternal service responds; includes HTTP/gRPC status
tool_resultTool returns to agent
agent_endAgent signals completion
agent_terminateAgent session cleaned up; includes final status
run_endRun complete
GET /api/{run_id}/lineage

Returns the complete agent spawn tree with delegation edges, timing, and per-agent status. Use this to verify the hierarchy formed correctly and to trace which agent called which service.

GET /api/{run_id}/events streams structured logs alongside agent events. The web UI at /logs shows them color-coded by category: service calls in teal, agent actions in blue, errors in red.


Tool retries. Each tool call retries up to 3 times on transient errors before propagating the failure to the agent.

Circuit breaker. app/services/resilience.py tracks error rates per provider. A provider that fails repeatedly trips its circuit breaker, causing subsequent calls to fail immediately rather than waiting for timeouts. This prevents one degraded provider from stalling the entire run.

Partial completion. A failed invoice does not abort the entire regional batch. The Regional Orchestrator continues processing remaining invoices and reports failures in its result. Finance Control aggregates partial results across regions.

Cancellation. POST /api/{run_id}/cancel triggers runner.cancel_subtree(root_agent_id), which terminates all running agents in the tree from the bottom up.


Terminal window
pytest tests/ -v

Tests cover agent lifecycle behavior, topology graph correctness, and provider transport logic. They run against the same mock services and do not require a live Caracal stack.


The Lynx Capital application demonstrates the authorization patterns that Caracal enforces:

To add a new agent layer: Define a role in app/agents/roles.py with a scope template and permitted tools. Add the spawn logic to the orchestrator that should create it. The scope template becomes the Caracal scope claim when delegation is used.

To add a new provider: Add the provider to config/company.yaml, create a cases.json in _mock/, add a router in _mock/rest/routers/, and register the dispatch handler in app/services/registry.py.

To wire in the Caracal SDK: The authorization patterns in this example — spawn, delegate, scoped mandates, transport headers — map directly to Caracal.from_env(), caracal.spawn(), caracal.delegate(), and caracal.transport() in the Python SDK. The application’s AgentRunner.spawn() and event delegation edges are the conceptual equivalents. Replace them with the SDK to get cryptographic enforcement of scope boundaries instead of application-level simulation.