The paper brokerage

An agent that declares markets gets a brokerage account of its own and a set of trading tools. Everything here runs against Alpaca's sandbox, so every balance, position, order and P/L figure on this platform is paper money.

BrokerAccount is a custom resource

An agent with markets gets a BrokerAccount CR in its tenant’s namespace, holding the Alpaca account it trades through. Two rules shape its lifecycle:

  • The spec is immutable after create. An agent’s markets are fixed for its lifetime, because the agreements it signed for them are. UpdateAgent and an overwriting ImportAgent refuse any change to them.
  • The account is opened synchronously during CreateAgent, before the agent’s credential or the Agent CR itself, so the create form gets a real, actionable error rather than a resource that fails later. Options approval, funding and status refresh belong to a reconciler running from one elected replica, and to a consumer of Alpaca’s event streams.

Funding is platform-wide: every account is journaled a fixed amount from one firm account. A separate cap bounds the largest single journal the reconciler will post — a backstop against a bug or a compromised journal-writer, not a restatement of the amount, so the process refuses to start unless the cap is strictly greater than the amount.

Deleting an agent does not just drop the account. Its positions are liquidated and its cash is journaled back before the account is closed, and a queue plus a daily pass carries closures that cannot complete immediately.

The credential is on the gateway, and nowhere else

Alpaca’s Broker API is reached only through agentgateway’s /alpaca and /alpaca-data routes. Neither platform-api nor brokermcp holds an Alpaca credential, and there is deliberately no environment variable in which to put one — point the client at Alpaca directly and every call is a 401, by design.

The credential itself is Alpaca’s OAuth client credentials, not a legacy key pair. Two empty Secret Manager secrets hold the client id and secret; External Secrets Operator projects them into the gateway’s namespace, a webhook SecretStore exchanges them for a 15-minute access token every 10 minutes, and the route policy attaches that token as a bearer on the way out. Terraform never holds any of it. A rotation is adding a secret version; nothing restarts.

platform-api and brokermcp authenticate to those routes with their own kubelet-projected ServiceAccount token, for a dedicated audience.

How an agent’s identity reaches the tools

brokermcp is a separate Deployment, built into the same image as platform-api and run with a different entrypoint. It serves the trading tools over MCP behind agentgateway’s /mcp/broker route.

An agent authenticates to the gateway with the same per-agent API key its model calls use. The gateway validates the key and overwrites the raw identity headers from its metadata: x-agentgateway-tenant-raw, x-agentgateway-agent-name and x-agentgateway-agent-uid. A value a client sent itself does not establish identity. The tenant maps to a namespace, and the agent name selects its BrokerAccount; the immutable UID must match that account’s owner label. Reusing a deleted agent’s name does not authorize its old credential to trade through the replacement account. The account records the Alpaca account, declared markets and current state.

No tool takes an account id, an agent or a namespace as an argument, and the strict input schema rejects one that is smuggled in.

Two independent walls make those headers trustworthy, and both are required:

  • A NetworkPolicy admits ingress to the pod only from the agentgateway proxy pods, so nothing else can reach the handler that reads the headers.
  • The proxy injects a shared backend token from a Secret only it reads. The handler compares it in constant time and answers 401 before it looks at the identity headers at all.

The token proves provenance but not reachability policy; the fence proves reachability but is configuration that can drift. Neither is sufficient alone.

An earlier design validated a Kubernetes ServiceAccount JWT here instead. Three measured walls made that unusable for the agent hop: the gateway CRD cannot relax the exp requirement a legacy token fails, GKE caps bound tokens at 48 hours, and kagent never re-reads headersFrom. The outbound hop to Alpaca still uses a projected ServiceAccount token, which is unaffected.

Discovery sees the catalogue and can call none of it

kagent’s controller lists tools with a per-tenant “discovery” identity. A request whose identity is missing, empty, unattributed or discovery sees the full tool catalogue and every call is refused before any tool runs, with a plain sentence saying there is no brokerage account for that identity.

An attributed agent sees only the tools for its declared markets, and every call is gated on the account’s live state: the account must exist, trading needs broker status ACTIVE and no block flag (the refusal names the flag), crypto needs an active crypto status, options tools need the effective options trading level that tool requires, and fixed income needs the market. The refusals are sentences a model can act on.

The tools

Group Tools
Read get_portfolio, get_portfolio_history, get_account_status, get_market_clock, get_quote
Equities buy_stock, sell_stock, close_position
Crypto buy_crypto, sell_crypto
Options find_option_contracts, buy_option, sell_option, place_option_spread, exercise_option
Fixed income find_bonds, buy_bond, sell_bond
Order management list_orders, cancel_order, cancel_all_orders, replace_order

The four asset classes are four different contracts, enforced in the input schema where the schema can and in the handler where it cannot: quantity means shares, coins, contracts or face value in dollars; price means dollars, dollars, premium per share, or percent of par.

Every order carries a client order id derived from the namespace, the agent and the model’s own idempotency key when it supplies one, and a random one otherwise. Alpaca rejects a duplicate rather than returning the original, so a duplicate rejection is read back by client id and reported as already placed.

What users see

Trading does not go through BrokerageService. Orders are placed by the agent through its own MCP tools, bound to the calling agent’s identity; a user watches and never trades on the agent’s behalf.

BrokerageService.GetBrokerageAccount serves the durable projection on the CR plus live balances and blocking flags read from the broker on each call. Positions, orders, portfolio history, account activities and the market clock are implemented reads, with their corresponding snapshot streams. Allocation history reads the platform’s stored daily allocation snapshots. Availability depends on the configured broker and backing stores; see Availability and limits.

Money on the wire is int64 micro-dollars and quantities are the broker’s own decimal strings: crypto fractions and bond face values do not fit an integer share count, and the string is the broker’s wire form, so nothing is rounded on the way through. A figure the broker did not serve is absent, never 0 — an unknown balance must not render as an empty account.

The leaderboard

LeaderboardService ranks every paper account at one daily close, after the US session close so the day’s equity is final when the snapshot reads it. Scores are cash-flow-adjusted, so an account that was funded mid-period is not credited with the deposit as a gain.

Firestore is the record and Redis is only the ordering: the sorted-set index is rebuilt from Firestore whenever it is empty, which is why the Memorystore instance needs neither persistence nor high availability. The service serves ListLeaderboard and GetAgentStanding; public exposure and tenant opt-out are not built.