# Components
Source: https://docs.gomajordomo.com/architecture/components
What Steward, Butler, and the Web app do — and how they fit together.
Majordomo has three parts that work together. This page defines each clearly and links to deeper docs.
## Steward (Gateway)
A transparent HTTP proxy that sits between your application and LLM providers. Steward handles all request traffic: it proxies calls to OpenAI, Anthropic, Gemini, Bedrock, and OpenAI-compatible providers (Fireworks, Together, DeepSeek); parses token usage; calculates cost using live pricing data; writes full request/response bodies to your S3/GCS bucket; and logs only metadata (tokens, cost, latency, model, custom tags) to Majordomo. Streaming is handled transparently.
**Managed** — Majordomo operates Steward on its own infrastructure. You configure your cloud storage bucket, create an API key, and point your SDK at the gateway endpoint. No servers to run.
**Self-hosted** — You run Steward inside your own VPC. Your prompts and completions never leave your network — not even to Majordomo. Only metadata (token counts, cost, latency, model name) is sent outbound to Majordomo Cloud. The right choice for teams with data residency requirements or enterprise contracts that specify where AI data is processed.
Both modes write request/response bodies to your bucket. The difference is where Steward runs.
See also:
* [How It Works](/architecture/how-it-works)
* [Cloud Body Storage](/configuration/cloud-storage)
* [Request Headers](/reference/headers)
* [Self-hosted Setup](/enterprise/steward-setup)
## Butler (Control Plane API)
Majordomo’s cloud service backing the dashboard and advanced features.
* Stores usage metadata and serves analytics for the Web app
* Manages API keys, steward tokens, and provider mappings (encrypted at rest)
* Orchestrates Replay and Evals jobs and stores their results
* Maintains metadata key discovery and indexing (for fast filters)
## Web (Dashboard)
The Majordomo dashboard UI, powered by Butler’s APIs.
* Usage explorer, cost breakdowns, and request detail
* Metadata Keys management (discover, label, activate/index)
* Replay and Evals creation, status, and results
* Settings for Cloud Body Storage (S3/GCS) and Provider API keys
Related guides:
* [Metadata Keys](/guides/metadata-keys)
* [Replay](/guides/replay)
* [Evals](/guides/evals)
# How It Works
Source: https://docs.gomajordomo.com/architecture/how-it-works
Majordomo is a transparent proxy. Your prompts go to your bucket. Metadata goes to Majordomo.
## Architecture
Request and response bodies are written directly to your S3 or GCS bucket. Majordomo's servers receive only metadata — token counts, cost, latency, model name, and whatever custom tags you attach. This is not a configuration option or a compliance mode. It is how the product is built.
For a glossary of roles and responsibilities, see [Components](/architecture/components).
***
## Two deployment modes
### Managed
Majordomo operates Steward on its own infrastructure. You connect your cloud storage bucket, create an API key, and point your SDK at the gateway endpoint. No servers to run or maintain.
### Self-hosted Steward (VPC)
You run Steward inside your own VPC. Your prompts and completions are processed entirely within your network — they never touch Majordomo's infrastructure. Only metadata (token counts, cost, latency, model name) leaves your environment, sent to Majordomo Cloud to power the dashboard.
This is the right choice when your team has data residency requirements, when enterprise customers ask where their data is processed, or when you need to pass a security review that requires prompt content to stay on-premises.
Both modes write request/response bodies to your bucket. The difference is where Steward runs.
[Self-hosted setup →](/enterprise/steward-setup)
***
## Request flow
On every request, the gateway:
1. Validates the `X-Majordomo-Key` header
2. Detects the provider from the request path or `X-Majordomo-Provider` header
3. Forwards the request to the upstream provider unchanged
4. Parses the response for token usage
5. Calculates cost using real-time pricing data
6. Writes the request and response body to your S3 / GCS bucket
7. Logs metadata to Majordomo asynchronously — no latency added to the critical path
8. Returns the response to the caller — identical to calling the provider directly
***
## What goes where
| Data | Destination | Who controls it |
| ------------------ | ------------------------------------------------------ | ---------------------- |
| Prompt content | Your S3 / GCS bucket | You |
| Completion content | Your S3 / GCS bucket | You |
| Token counts | Majordomo Cloud | Majordomo |
| Cost | Majordomo Cloud (calculated locally, sent as a number) | Majordomo |
| Latency | Majordomo Cloud | Majordomo |
| Model name | Majordomo Cloud | Majordomo |
| Custom tags | Majordomo Cloud (only `X-Majordomo-*` headers you add) | You decide what to tag |
| Provider API keys | Your gateway database, encrypted at rest | You |
***
## Provider detection
The gateway auto-detects the provider from the request path:
| Path | Provider |
| -------------------------- | --------- |
| `/v1/chat/completions` | OpenAI |
| `/v1/messages` | Anthropic |
| `/:generateContent` | Gemini |
Override with the `X-Majordomo-Provider` header when needed.
***
## Pricing
Costs are calculated using pricing data fetched hourly from [llm-prices.com](https://llm-prices.com), with a bundled fallback. Provider model names are mapped to canonical names before lookup. Prompt caching tokens are tracked and priced separately.
# API Keys
Source: https://docs.gomajordomo.com/configuration/api-keys
Create and manage Majordomo API keys.
Majordomo API keys (`mdm_sk_...`) authenticate requests through the gateway. Every proxied request must include a valid key in the `X-Majordomo-Key` header. Usage is tracked per key.
## Managing keys in the dashboard
Create, revoke, and monitor keys from the **API Keys** section of the [Majordomo dashboard](https://app.gomajordomo.com). The dashboard shows per-key request counts, costs, and last-used timestamps.
The plaintext key is shown once at creation time. Store it in your secrets manager (AWS Secrets Manager, 1Password, GitHub Secrets, etc.).
## Managing keys via CLI
```bash theme={null}
# Create
majordomo keys create --name "Production"
# List
majordomo keys list
# Revoke
majordomo keys revoke
```
## Using keys in requests
Pass the key in the `X-Majordomo-Key` header on every request:
```python theme={null}
client = OpenAI(
base_url="https://gateway.gomajordomo.com/v1",
api_key="your-openai-api-key",
default_headers={"X-Majordomo-Key": "mdm_sk_your_key_here"},
)
```
Keys are validated on every request. Invalid or revoked keys return `401 Unauthorized`.
## Key strategy
Use separate keys for separate concerns:
| Key | Purpose |
| ------------- | ------------------------------- |
| `production` | Production application traffic |
| `staging` | Staging environment |
| `dev-alice` | Individual developer |
| `experiments` | A/B tests and model experiments |
This gives you cost and usage breakdowns per environment or team member in the dashboard without any additional configuration.
# CLI
Source: https://docs.gomajordomo.com/configuration/cli
Install and use the Majordomo CLI to authenticate and manage API keys and Steward instances from your terminal.
The `majordomo` CLI lets you authenticate, manage API keys, and administer self-hosted Steward instances without leaving the terminal.
## Install
Install with Homebrew:
```bash theme={null}
brew install go-majordomo/majordomo/majordomo
```
Verify:
```bash theme={null}
majordomo version
```
## Global flags
| Flag | Default | Description |
| ----------------- | -------------------------------- | ---------------------------------------------------------- |
| `--api-url ` | `https://butler.gomajordomo.com` | Butler API base URL. Override for self-hosted deployments. |
## Authentication
### login
Opens a browser window, completes the OAuth flow, and stores your token locally at `~/.majordomo/config.yaml`.
```bash theme={null}
majordomo login
```
### logout
Removes stored credentials.
```bash theme={null}
majordomo logout
```
### whoami
Shows the currently authenticated user.
```bash theme={null}
majordomo whoami
```
Output: email, username, and user ID.
## API keys
Manage the Majordomo API keys (`mdm_sk_...`) used to authenticate gateway requests.
### keys create
```bash theme={null}
majordomo keys create --name "Production" [--description "Main production key"]
```
| Flag | Required | Description |
| --------------- | -------- | ------------------------- |
| `--name` | Yes | Display name for the key. |
| `--description` | No | Optional description. |
The key value is shown once at creation and cannot be retrieved afterward. Store it in your secrets manager immediately.
### keys list
```bash theme={null}
majordomo keys list
```
Output columns: ID, NAME, STATUS, REQUESTS, CREATED.
### keys revoke
```bash theme={null}
majordomo keys revoke
```
Revokes the key immediately. All gateway requests using it will be rejected.
## Stewards
Manage self-hosted Steward instances registered to your account. Each Steward gets a token (`mdm_st_...`) it uses to authenticate with Butler.
### stewards create
```bash theme={null}
majordomo stewards create --name "prod-vpc"
```
| Flag | Required | Description |
| -------- | -------- | ----------------------------- |
| `--name` | Yes | Display name for the Steward. |
The token is shown once at creation. Pass it to your Steward instance as `MAJORDOMO_STEWARD_TOKEN`.
### stewards list
```bash theme={null}
majordomo stewards list
```
Output columns: ID, NAME, STATUS, LAST SEEN, CREATED.
### stewards revoke
```bash theme={null}
majordomo stewards revoke
```
Revokes the Steward token. The Steward will stop being able to sync metadata to Butler.
## Steward admin
Commands for administering a running Steward instance directly. Requires the Steward's admin token.
```bash theme={null}
majordomo steward --url --admin-token
```
| Flag | Default | Description |
| --------------- | ----------------------- | --------------------------------------------------------------------------------- |
| `--url` | `http://localhost:7680` | Steward base URL. |
| `--admin-token` | — | Admin token for the Steward. Can also be set via `MAJORDOMO_STEWARD_ADMIN_TOKEN`. |
### steward orgs
List orgs registered on this Steward instance.
```bash theme={null}
majordomo steward --url https://steward.internal --admin-token $TOKEN orgs
```
Output columns: ORG ID, NAME, PENDING, BUTLER URL, REGISTERED.
### steward register
Register an org on this Steward instance. Run this once during initial setup to connect the Steward to Butler.
```bash theme={null}
majordomo steward --url https://steward.internal --admin-token $TOKEN register \
--token mdm_st_... \
--butler-url https://butler.gomajordomo.com
```
| Flag | Required | Description |
| -------------- | -------- | ------------------------------------------------------------- |
| `--token` | Yes | Steward token (`mdm_st_...`) obtained from `stewards create`. |
| `--butler-url` | Yes | Butler base URL. |
### steward deregister
Remove an org registration from this Steward instance.
```bash theme={null}
majordomo steward --url https://steward.internal --admin-token $TOKEN deregister
```
# Cloud Body Storage
Source: https://docs.gomajordomo.com/configuration/cloud-storage
Configure per-user or per-organization cloud storage so Steward uploads full request/response bodies to your own S3 or GCS bucket.
Majordomo uploads request/response bodies to cloud storage that you control. Configure this in the dashboard at the user or organization level. When configured, Steward writes bodies to those buckets and logs only metadata (tokens, cost, latency) to Majordomo.
If no personal/org Cloud Storage is configured, Steward does not store bodies by default. You can optionally enable local body columns in Postgres for debugging via `logging.store_request_body` / `logging.store_response_body` in Steward config.
## Where to configure
* Personal: Settings → Cloud Body Storage
* Organization: Settings → Organization Cloud Body Storage
Either scope works. If both are set, the personal bucket takes precedence for that user’s API keys.
## Amazon S3 setup
1. Create a bucket (e.g., `your-llm-logs`).
2. Choose an authentication method:
* Recommended: instance role / workload identity attached to where Steward runs; leave access keys blank.
* Alternative: Access Key ID / Secret Access Key scoped to this bucket.
3. In the dashboard form, select Provider = S3 and fill:
* Bucket (required)
* Region (default `us-east-1`)
* Endpoint (optional — for S3‑compatible services like MinIO or Cloudflare R2)
* Access Key ID / Secret Access Key (only if not using instance role)
4. Save. The status badge shows “S3 Configured”.
### What Steward writes
* Object per request with gzipped JSON containing request headers/body and response headers/body.
* Key format includes API key id, request id, and timestamp for easy lookup.
## Google Cloud Storage setup
1. Create a GCS bucket (e.g., `your-llm-logs`).
2. Create a service account (e.g., `majordomo-storage@.iam.gserviceaccount.com`) and grant Storage Object Admin on the bucket.
3. Generate a JSON key for that service account.
4. In the dashboard form, select Provider = GCS and fill:
* Bucket (required)
* Project ID (optional but recommended)
* Service Account JSON (paste the key JSON)
5. Save. The status badge shows “GCS Configured”.
## Data handling rules
* Bodies are uploaded to your bucket when a valid per‑user or per‑org config exists.
* If neither is configured, Steward will not upload bodies; metadata is still logged.
## Troubleshooting
* “Configured” badge missing: open Settings → Cloud Body Storage and ensure required fields are saved for the correct scope.
* S3 upload errors: verify IAM permissions for PutObject/ListBucket and that Region/Endpoint are correct. MinIO/R2 require setting the custom Endpoint.
* GCS upload errors: confirm the service account has Storage Object Admin and the pasted JSON is valid.
## Related Steward variables
Cloud storage credentials and bucket details are managed in the dashboard and encrypted at rest. You rarely need to touch these, but Steward exposes env vars for Postgres body storage if needed for local debugging:
| Variable | Default | Description |
| ------------------------- | ------- | ------------------------------------------------------ |
| `LOG_STORE_REQUEST_BODY` | `false` | Store request bodies in Postgres. For debugging only. |
| `LOG_STORE_RESPONSE_BODY` | `false` | Store response bodies in Postgres. For debugging only. |
Prefer configuring cloud storage in the dashboard over Postgres body storage for production use.
# Configuration Reference
Source: https://docs.gomajordomo.com/configuration/overview
All environment variables for the Majordomo Steward.
Steward is configured entirely through environment variables. There is no config file. Pass variables via your orchestrator (`docker run -e`, Kubernetes Secrets, ECS task definitions, etc.) or a `.env` file for local development.
## Required
| Variable | Description |
| ------------------- | ------------------------------------------------------------------------------------------------------------ |
| `ENCRYPTION_KEY` | 64-character hex string. Used to encrypt provider credentials at rest. Generate with `openssl rand -hex 32`. |
| `POSTGRES_HOST` | PostgreSQL host. |
| `POSTGRES_USER` | PostgreSQL user. |
| `POSTGRES_PASSWORD` | PostgreSQL password. |
| `POSTGRES_DB` | PostgreSQL database name. Default: `majordomo_steward`. |
## Server
| Variable | Default | Description |
| ------------------ | --------- | -------------------------------------------------------------------- |
| `HOST` | `0.0.0.0` | Bind address. |
| `PORT` | `7680` | HTTP port. |
| `READ_TIMEOUT` | `30s` | Max duration for reading the full request. |
| `WRITE_TIMEOUT` | `600s` | Max duration for writing the full response (set high for streaming). |
| `UPSTREAM_TIMEOUT` | `600s` | Max duration to wait for the upstream LLM provider. |
## PostgreSQL
| Variable | Default | Description |
| -------------------- | --------- | --------------------------------------------------- |
| `POSTGRES_PORT` | `5432` | PostgreSQL port. |
| `POSTGRES_SSLMODE` | `disable` | SSL mode: `disable`, `require`, `verify-full`, etc. |
| `POSTGRES_MAX_CONNS` | `20` | Connection pool size. |
## Logging
| Variable | Default | Description |
| ------------------------- | ------- | ------------------------------------------------------------------------------ |
| `LOG_LEVEL` | `info` | Log verbosity: `debug`, `info`, `warn`, `error`. |
| `LOG_STORE_REQUEST_BODY` | `false` | Store request bodies in Postgres. Off by default — use cloud storage instead. |
| `LOG_STORE_RESPONSE_BODY` | `false` | Store response bodies in Postgres. Off by default — use cloud storage instead. |
## Metadata sync
| Variable | Default | Description |
| ------------------- | ------- | --------------------------------------------------- |
| `BATCH_INTERVAL` | `60s` | How often Steward syncs usage metadata to Butler. |
| `BATCH_MAX_SIZE` | `500` | Maximum rows per sync batch. |
| `KEY_SYNC_INTERVAL` | `5m` | How often Steward polls Butler for API key updates. |
## Pricing
| Variable | Default | Description |
| -------------------------- | -------------------------------------------- | ----------------------------------------------------- |
| `PRICING_REMOTE_URL` | `https://www.llm-prices.com/current-v1.json` | Source for live model pricing. Fetched hourly. |
| `PRICING_REFRESH_INTERVAL` | `1h` | How often to refresh pricing data. |
| `PRICING_FALLBACK_FILE` | `./pricing.json` | Local fallback if remote fetch fails. |
| `PRICING_ALIASES_FILE` | `./model_aliases.json` | Maps provider model names to canonical pricing names. |
## Provider overrides
Override the base URL for any LLM provider. Useful when routing through an internal gateway or private endpoint.
| Variable | Default |
| -------------------- | ------------------------------------------- |
| `OPENAI_BASE_URL` | `https://api.openai.com` |
| `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` |
| `GEMINI_BASE_URL` | `https://generativelanguage.googleapis.com` |
## Admin API
| Variable | Default | Description |
| --------------------- | ------- | --------------------------------------------------------------------------------------- |
| `STEWARD_ADMIN_TOKEN` | — | When set, enables the admin API for managing org registration and checking sync status. |
## Secrets management
Set `ENCRYPTION_KEY` and `POSTGRES_PASSWORD` via your secrets manager — never in source control. In Kubernetes, mount them as Secrets. In ECS, reference them from Parameter Store or Secrets Manager. In Docker, use an `.env` file outside of version control.
# Steward Setup
Source: https://docs.gomajordomo.com/enterprise/steward-setup
Run the Majordomo Steward in your own VPC. Your prompts never leave your infrastructure.
The Steward is a lightweight agent that runs inside your infrastructure. It proxies LLM calls locally, writes request and response bodies to your S3 or GCS bucket, and sends only metadata to Majordomo Cloud. The dashboard works identically to the managed deployment.
## How it fits together
* **Steward** — runs in your VPC. Handles all LLM traffic. Writes bodies to your bucket. Reports metadata to Butler.
* **Butler + dashboard** — runs in Majordomo's cloud. Backs the web UI, manages API keys, dispatches replay and eval jobs. Never receives prompt content.
## What you need
* Docker (or Kubernetes) in your VPC
* PostgreSQL 14+ (not bundled — use RDS, Cloud SQL, or self-managed)
* An S3 bucket (AWS) or GCS bucket (GCP) for body storage
* A Majordomo Enterprise account
## Setup
This section covers running Steward in your VPC with Postgres and optional S3/GCS body storage.
### Prerequisites
* Docker (or Kubernetes)
* PostgreSQL 14+
* Optional: S3 or GCS bucket for body storage
* Majordomo Enterprise account and a Steward token (`mdm_st_...`)
### 1) Build or pull the image
From source:
```bash theme={null}
cd majordomo-steward
docker build -t majordomo-steward:latest .
```
### 2) Configure environment
Set the minimum required environment. You can pass these via `docker run -e` or your orchestrator.
```bash theme={null}
export ENCRYPTION_KEY=<64-hex-bytes> # required for encrypting secrets at rest
export POSTGRES_HOST=localhost
export POSTGRES_PORT=5432
export POSTGRES_USER=majordomo
export POSTGRES_PASSWORD=...
export POSTGRES_DB=majordomo_steward
```
Optional (defaults are sensible): `LOG_LEVEL`, `PRICING_REMOTE_URL`, `PRICING_ALIASES_FILE`, provider `*_BASE_URL`s.
### 3) Initialize the database
```bash theme={null}
docker run --rm \
-e ENCRYPTION_KEY -e POSTGRES_HOST -e POSTGRES_PORT \
-e POSTGRES_USER -e POSTGRES_PASSWORD -e POSTGRES_DB \
majordomo-steward:latest migrate
```
### 4) Register with Butler
Create a Steward token in the Majordomo dashboard or via the CLI (`majordomo stewards create --name "prod"`). The token has the format `mdm_st_...` and is shown once at creation time — store it in your secrets manager. Then:
```bash theme={null}
docker run --rm \
-e ENCRYPTION_KEY -e POSTGRES_HOST -e POSTGRES_PORT \
-e POSTGRES_USER -e POSTGRES_PASSWORD -e POSTGRES_DB \
majordomo-steward:latest register \
--token $MDM_STEWARD_TOKEN \
--butler-url https://butler.gomajordomo.com
```
This stores the encrypted token and org details for job dispatch and usage ingest.
### 5) Run Steward
```bash theme={null}
docker run -d --name majordomo-steward \
-p 7680:7680 \
-e ENCRYPTION_KEY -e POSTGRES_HOST -e POSTGRES_PORT \
-e POSTGRES_USER -e POSTGRES_PASSWORD -e POSTGRES_DB \
majordomo-steward:latest
```
### 6) Configure body storage (optional but recommended)
In the Web dashboard, set up [Cloud Body Storage](/configuration/cloud-storage) at the Personal or Organization scope. Steward will upload request/response bodies there; Majordomo receives metadata only.
### 7) Verify
Point your SDK at the Steward and send a test request with `X-Majordomo-Key`. The request should appear in the dashboard with tokens, cost, and latency.
### Notes
* See [Request Headers](/reference/headers) for tagging and provider override options.
## What gets logged where
| Data | Destination | Who controls it |
| --------------------------- | ------------------------------------------ | --------------- |
| Prompt content | Your S3 / GCS bucket | You |
| Completion content | Your S3 / GCS bucket | You |
| Token counts, cost, latency | Majordomo Cloud | Majordomo |
| Provider API keys | Your Steward's Postgres, encrypted at rest | You |
For security questionnaire answers and a data flow diagram, see [Security & Compliance](/guides/enterprise-security).
# Agent Run Tracking
Source: https://docs.gomajordomo.com/guides/agent-runs
Group the many LLM calls of one conversation or agent run into a single run — with a rolled-up cost and a nested waterfall of which step drove which calls.
Conversational agents and workflow agents make **many LLM calls per task** — a planning
call, tool calls, follow-ups. By default the gateway logs each as an independent request.
Agent run tracking lets you stamp those calls with a shared id so the dashboard shows them
as **one run**: a rolled-up total cost, plus a nested **waterfall** of which tool/agent step
drove which calls and what each cost.
Because the gateway is a proxy, it sees your LLM calls but never the tool/agent code that
runs between them. So the tree is reconstructed from headers **your client sends** — no SDK
or in-app tracer required. Any client that can set request headers works.
## The headers
| Header | Required | Value |
| ----------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-Majordomo-Trace-Id` | To join a run | One id per conversation / agent run. Generate it once when the run starts and send it on **every** LLM call in that run (any opaque string). |
| `X-Majordomo-Span-Path` | No | `/`-joined names of the ancestor **steps** from the run root down to this call's parent, e.g. `planner/tool:search_db`. Omit to hang the call directly under the run root. |
| `X-Majordomo-Span-Name` | No | Label for this call in the waterfall. Defaults to the model name. |
**Graceful degradation.** A trace id alone gives you a **flat run rollup** — every call in
the run plus its total cost. Adding a span path gives you the **waterfall** — the calls
grouped under named tool/agent steps, with cost rolled up each step. Start with just a trace
id; add paths when you want the tree.
`/` is the reserved separator between step names. If a step name itself contains a `/`,
percent-encode it so it doesn't split into extra nodes.
## Modeling the tree
Send, on each LLM call, the **path of the steps above it**. Interior tool/agent nodes don't
make their own request — they're inferred from the paths of the calls beneath them.
For an agent that plans, then calls a `search_db` tool that makes two calls:
```
planner # X-Majordomo-Span-Path: planner
├─ route $0.004 # Span-Name: route
└─ tool:search_db # X-Majordomo-Span-Path: planner/tool:search_db
├─ summarize $0.021 # Span-Name: summarize
└─ rank $0.027 # Span-Name: rank
```
The `tool:search_db` node's cost (`$0.048`) is the sum of the calls beneath it; the `planner`
node totals the whole run.
## Sending the headers
```python Python (majordomo-llm) theme={null}
import os, uuid
from majordomo_llm import get_llm_instance
llm = get_llm_instance(
"anthropic", "claude-sonnet-4-6",
base_url=os.environ["MAJORDOMO_GATEWAY_URL"],
default_headers={"X-Majordomo-Key": os.environ["MAJORDOMO_API_KEY"]},
)
# One id per conversation / agent run, reused across every call in the run.
trace_id = str(uuid.uuid4())
await llm.get_response(
user_prompt="Which tool should I use?",
extra_headers={
"X-Majordomo-Trace-Id": trace_id,
"X-Majordomo-Span-Path": "planner",
"X-Majordomo-Span-Name": "route",
},
)
await llm.get_response(
user_prompt="Summarize these rows: ...",
extra_headers={
"X-Majordomo-Trace-Id": trace_id,
"X-Majordomo-Span-Path": "planner/tool:search_db",
"X-Majordomo-Span-Name": "summarize",
},
)
```
```python Python (OpenAI SDK) theme={null}
from openai import OpenAI
client = OpenAI(
base_url="https://gateway.gomajordomo.com/v1",
api_key="your-openai-key",
default_headers={"X-Majordomo-Key": "mdm_sk_your_key_here"},
)
client.chat.completions.create(
model="gpt-4o",
messages=[...],
extra_headers={
"X-Majordomo-Trace-Id": "run_7f3a",
"X-Majordomo-Span-Path": "planner/tool:search_db",
"X-Majordomo-Span-Name": "summarize",
},
)
```
```bash curl theme={null}
curl -X POST https://gateway.gomajordomo.com/v1/chat/completions \
-H "Authorization: Bearer your-openai-key" \
-H "X-Majordomo-Key: mdm_sk_your_key_here" \
-H "X-Majordomo-Trace-Id: run_7f3a" \
-H "X-Majordomo-Span-Path: planner/tool:search_db" \
-H "X-Majordomo-Span-Name: summarize" \
-H "Content-Type: application/json" \
-d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "..."}]}'
```
### Frameworks that hide per-request headers
Pydantic-AI, Mastra, and similar frameworks don't expose a per-request header hook on
`agent.run()` — they own the model-call loop internally. Two options:
* **A client per conversation** — build the model/client with the trace id in its default
headers and use it for that conversation's calls.
* **Context-scoped injection** — keep one client and inject the current run's headers from a
context store, so every model request the framework makes picks them up automatically. This
is usually the cleaner option, shown below.
The idea is the same in both languages: hold the current run/step headers in a context store
(`contextvars` in Python, `AsyncLocalStorage` in Node), and give the model client a
custom HTTP layer that reads that store on every request. You then set the store once around
each `agent.run()` — every internal model call in that run (including tool-loop calls) inherits
the headers. The natural granularity is **per agent/tool step**, which is exactly the
`span_path` you want: set the store before each agent or tool boundary.
```python Pydantic-AI (Python) theme={null}
import contextvars, os, uuid
import httpx
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider
# Holds the headers for whichever run/step is currently executing.
_run_ctx: contextvars.ContextVar[dict[str, str]] = contextvars.ContextVar("majordomo_run", default={})
async def _inject_majordomo_headers(request: httpx.Request) -> None:
for key, value in _run_ctx.get().items():
if value:
request.headers[key] = value
# One client; the X-Majordomo-Key is constant, the run/step headers come from the hook.
http_client = httpx.AsyncClient(
headers={"X-Majordomo-Key": os.environ["MAJORDOMO_API_KEY"]},
event_hooks={"request": [_inject_majordomo_headers]},
)
model = OpenAIChatModel(
"gpt-4o",
provider=OpenAIProvider(
base_url=f"{os.environ['MAJORDOMO_GATEWAY_URL']}/v1",
api_key=os.environ["OPENAI_API_KEY"], # provider key, passed through Steward
http_client=http_client,
),
)
agent = Agent(model)
def run_headers(trace_id: str, span_path: str | None = None, span_name: str | None = None) -> dict[str, str]:
h = {"X-Majordomo-Trace-Id": trace_id}
if span_path:
h["X-Majordomo-Span-Path"] = span_path
if span_name:
h["X-Majordomo-Span-Name"] = span_name
return h
trace_id = str(uuid.uuid4()) # one id per conversation / agent run
# Set the store before each step. All of this run's model calls inherit the headers.
_run_ctx.set(run_headers(trace_id, "planner/tool:search_db", "summarize"))
result = await agent.run("Summarize these rows: ...")
# Under concurrency (asyncio.gather of multiple runs), call _run_ctx.set(...) *inside*
# each task — contextvars are copied per task, so each run keeps its own headers.
```
```typescript Mastra (TypeScript) theme={null}
import { AsyncLocalStorage } from 'node:async_hooks';
import { createOpenAI } from '@ai-sdk/openai';
import { Agent } from '@mastra/core/agent';
// Holds the headers for whichever run/step is currently executing.
const runContext = new AsyncLocalStorage>();
// Custom fetch: adds the constant key plus the current run/step headers to every request.
const majordomoFetch = (input: RequestInfo | URL, init: RequestInit = {}): Promise => {
const headers = new Headers(init.headers);
headers.set('X-Majordomo-Key', process.env.MAJORDOMO_API_KEY!);
for (const [key, value] of Object.entries(runContext.getStore() ?? {})) {
headers.set(key, value);
}
return fetch(input, { ...init, headers });
};
const openai = createOpenAI({
baseURL: `${process.env.MAJORDOMO_GATEWAY_URL}/v1`,
apiKey: process.env.OPENAI_API_KEY, // provider key, passed through Steward
fetch: majordomoFetch,
});
const agent = new Agent({
name: 'support-copilot',
instructions: '...',
model: openai('gpt-4o'),
});
const traceId = crypto.randomUUID(); // one id per conversation / agent run
// Run each step inside the store; every model call in the run inherits the headers.
await runContext.run(
{
'X-Majordomo-Trace-Id': traceId,
'X-Majordomo-Span-Path': 'planner/tool:search_db',
'X-Majordomo-Span-Name': 'summarize',
},
() => agent.generate('Summarize these rows: ...'),
);
```
`AsyncLocalStorage` isolates concurrent runs automatically; in Python, set the `contextvars`
value inside each task so parallel runs don't share one store.
## Viewing runs
In the dashboard, open **Observe → Runs**:
* The **runs list** shows one row per run with its rolled-up cost, token totals, call count,
and duration.
* Opening a run shows the **waterfall**: the nested steps and LLM calls with per-node cost.
Click any LLM node to open its full request detail.
Runs and **Logs** are two views of the same requests. Every request still appears in Logs;
a request that belongs to a run gets a **Run** link there, and a quick **Exclude runs**
toggle hides run requests when you want only standalone traffic.
## Querying runs directly
The headers are stored as first-class columns on `llm_requests`, so a run's cost is a plain
aggregate (see the [data model](/reference/schema)):
```sql theme={null}
-- Cost and calls per run in the last 7 days
SELECT
trace_id,
COUNT(*) AS calls,
SUM(total_cost) AS total_cost,
MIN(requested_at) AS started_at,
MAX(responded_at) AS ended_at
FROM llm_requests
WHERE trace_id IS NOT NULL
AND requested_at > now() - interval '7 days'
GROUP BY trace_id
ORDER BY total_cost DESC;
```
```sql theme={null}
-- Cost per step within one run
SELECT
split_part(span_path, '/', 1) AS top_step,
span_path,
COUNT(*) AS calls,
SUM(total_cost) AS step_cost
FROM llm_requests
WHERE trace_id = 'run_7f3a'
GROUP BY span_path
ORDER BY span_path;
```
## Runs vs. metadata
Use **agent run tracking** to group the calls of one execution into a tree. Use
[metadata headers](/guides/cost-attribution) (feature, team, environment, user) to attribute
cost across dimensions. They compose: stamp a run with a trace id **and** a
`X-Majordomo-Feature` so you can both drill into a single run and roll runs up by feature.
# Cost Attribution
Source: https://docs.gomajordomo.com/guides/cost-attribution
Tag every LLM request with custom metadata and break down spend by team, feature, environment, or any dimension you care about.
Every request through the gateway can carry custom metadata via `X-Majordomo-*` headers. This metadata is stored with the request log and queryable in the dashboard and directly in Postgres.
## Adding metadata
Any header prefixed with `X-Majordomo-` (except `-Key` and `-Provider`) is stored as metadata:
```python Python (OpenAI) theme={null}
response = client.chat.completions.create(
model="gpt-4o",
messages=[...],
extra_headers={
"X-Majordomo-Feature": "document-review",
"X-Majordomo-Team": "legal",
"X-Majordomo-Environment": "production",
"X-Majordomo-User-Tier": "enterprise",
}
)
```
```python Python (Anthropic) theme={null}
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[...],
extra_headers={
"X-Majordomo-Key": "mdm_sk_your_key_here",
"X-Majordomo-Feature": "contract-analysis",
"X-Majordomo-User-Id": "user_456",
}
)
```
```bash curl theme={null}
curl -X POST https://gateway.gomajordomo.com/v1/chat/completions \
-H "X-Majordomo-Key: mdm_sk_your_key_here" \
-H "X-Majordomo-Feature: chat" \
-H "X-Majordomo-Team: product" \
-H "X-Majordomo-Environment: production" \
...
```
## Recommended dimensions
| Header | Example values | When to use |
| ------------------------- | ------------------------------------- | ---------------------------------------- |
| `X-Majordomo-Feature` | `chat`, `document-review`, `code-gen` | Per product feature |
| `X-Majordomo-Team` | `platform`, `data`, `legal` | Per team |
| `X-Majordomo-Environment` | `production`, `staging`, `dev` | Per environment |
| `X-Majordomo-User-Id` | `user_123` | Per end user (use an opaque ID, not PII) |
| `X-Majordomo-User-Tier` | `free`, `pro`, `enterprise` | Per pricing tier |
| `X-Majordomo-Experiment` | `model-test-v2` | Per A/B test or experiment |
Start with the dimensions you'll actually query. You can add more over time — new keys are stored immediately without any schema changes.
## Querying spend
### By feature
```sql theme={null}
SELECT
raw_metadata->>'Feature' AS feature,
COUNT(*) AS requests,
SUM(input_tokens) AS input_tokens,
SUM(output_tokens) AS output_tokens,
SUM(total_cost) AS total_cost
FROM llm_requests
WHERE raw_metadata->>'Feature' IS NOT NULL
GROUP BY 1
ORDER BY total_cost DESC;
```
### By team (monthly)
```sql theme={null}
SELECT
raw_metadata->>'Team' AS team,
date_trunc('month', requested_at) AS month,
SUM(total_cost) AS total_cost
FROM llm_requests
WHERE requested_at > now() - interval '3 months'
GROUP BY 1, 2
ORDER BY 2 DESC, 3 DESC;
```
### By model
```sql theme={null}
SELECT
model,
COUNT(*) AS requests,
SUM(total_cost) AS total_cost,
AVG(response_time_ms) AS avg_latency_ms
FROM llm_requests
GROUP BY model
ORDER BY total_cost DESC;
```
### Top spenders this week
```sql theme={null}
SELECT
raw_metadata->>'User-Id' AS user_id,
COUNT(*) AS requests,
SUM(total_cost) AS total_cost
FROM llm_requests
WHERE
requested_at > now() - interval '7 days'
AND raw_metadata->>'User-Id' IS NOT NULL
GROUP BY 1
ORDER BY total_cost DESC
LIMIT 20;
```
## Indexed metadata
High-cardinality metadata keys (user IDs, experiment names) can be activated for GIN indexing in the dashboard. Activated keys are copied to the `indexed_metadata` JSONB column, which supports fast `@>` queries.
```sql theme={null}
-- Fast query on an indexed key
SELECT COUNT(*), SUM(total_cost)
FROM llm_requests
WHERE indexed_metadata @> '{"Feature": "document-review"}';
```
Non-indexed keys are still queryable via `raw_metadata` — just without the index. Activate keys for dimensions you query frequently.
## Grouping an agent's calls
Metadata attributes cost across dimensions (feature, team, user). To instead group the
**multiple LLM calls of one conversation or agent run** into a single run — with a rolled-up
cost and a nested waterfall — use [Agent Run Tracking](/guides/agent-runs). The two compose:
tag a run with a trace id and an `X-Majordomo-Feature` to both drill into one run and roll
runs up by feature.
# Enterprise Security
Source: https://docs.gomajordomo.com/guides/enterprise-security
Answer security questionnaires, pass vendor reviews, and give your security team what they need — without changing how your product works.
If you're reading this, you're probably in one of two situations: a prospect just sent you a 40-question security questionnaire with "Where does your AI data go?" as question one, or your security team flagged your current LLM observability vendor as a risk. Either way, you need answers you can stand behind.
This page gives you the technical facts and the copy you need.
## The core guarantee
When you run Steward in your own infrastructure:
* **Prompt content never leaves your environment.** Steward runs inside your VPC, processes your LLM requests locally, and writes full request/response bodies to your own S3 or GCS bucket.
* **Majordomo's servers never receive prompt content.** The only data that flows outbound to Majordomo is request metadata: model name, token counts, cost, latency, and any custom tags you configure. No inputs, no outputs, no conversation history.
* **You own the storage.** Bodies go to a bucket in your AWS account or GCP project. You control the encryption keys, the retention policy, and who has access.
This is not a policy commitment or a contractual clause. It is the technical architecture. There is no pathway for prompt content to reach Majordomo's infrastructure, because Steward never sends it there.
## Data flow diagram
**What "metadata only" means in practice:**
| Field | Sent to Majordomo? |
| ------------------------------------------------------------- | -------------------------------------------- |
| Model name (e.g., `gpt-4o`) | Yes |
| Input token count | Yes |
| Output token count | Yes |
| Cost | Yes |
| Latency (ms) | Yes |
| Custom tags (`X-Majordomo-Feature`, `X-Majordomo-Team`, etc.) | Yes |
| Prompt text | **No** |
| System prompt | **No** |
| Response text | **No** |
| Conversation history | **No** |
| User-identifiable content | **No** (unless you add it as a metadata tag) |
## Security questionnaire answers
These answers apply to customers running **self-hosted Steward** in their own VPC. If you are using Managed (Majordomo-hosted) deployment, your requests are processed on Majordomo's infrastructure — these answers do not apply to your setup. Contact [security@gomajordomo.com](mailto:security@gomajordomo.com) for managed deployment security documentation.
The answers below are written from your perspective, for use in your own vendor questionnaires. Adapt them to match your specific infrastructure and policies before submitting.
***
**Where is your AI/LLM data processed?**
In our own infrastructure. We run the Majordomo Steward gateway inside our VPC. All LLM requests are processed locally; the gateway proxies requests directly to provider APIs (OpenAI, Anthropic, etc.) without routing through any third-party intermediary.
***
**Where is conversation data stored?**
In our own cloud storage. Full request and response bodies are written to an S3 bucket in our AWS account (or GCS bucket in our GCP project). We control the bucket, the encryption keys, and the retention policy.
***
**Does your LLM observability vendor receive prompt data?**
No. We use Majordomo for usage metrics and cost tracking. Steward runs in our infrastructure and transmits only metadata to Majordomo's servers: model names, token counts, costs, and latency. Prompt content and response content never leave our environment.
***
**What data does your AI vendor have access to?**
Majordomo has access to request metadata only: which models we call, how many tokens we use, and what it costs. No prompt content, no response content, no user data.
***
**Is PII processed by third-party AI infrastructure vendors?**
No PII is transmitted to Majordomo. Steward runs inside our own VPC and sends only non-content metadata outbound. Any PII that might appear in prompts is processed locally and written to storage we control. It never reaches Majordomo's servers.
***
**How is AI usage data encrypted in transit?**
Metadata transmitted to Majordomo is sent over TLS 1.2+. Request/response bodies stored in our S3/GCS bucket use AES-256 encryption at rest with our own CMK.
***
**Can you provide a data flow diagram?**
Yes — see the diagram in the architecture section of our security documentation. The short version: user requests → our gateway (our VPC) → provider API. Bodies → our S3. Metadata only → Majordomo.
***
**Does Majordomo have a SOC 2 report?**
Contact [security@gomajordomo.com](mailto:security@gomajordomo.com) for current compliance documentation.
***
## What to share with your security team
If your security team needs to review the architecture, point them here:
1. **This page** — the technical architecture and data flow
2. **[How It Works](/architecture/how-it-works)** — the full technical explainer
3. **Network egress rules** — Steward's only outbound destinations are your LLM providers, your own S3/GCS bucket, and Majordomo's metadata ingest endpoint (see the checklist below), so its behavior is verifiable at the network layer
Steward sends only the metadata documented above — no prompt or response content, and no other telemetry. Proxying and local logging don't depend on Majordomo's servers: if Majordomo's cloud is unavailable, Steward keeps proxying and logging locally, and queued metadata syncs once the connection is restored.
## Deployment
See [Steward Setup](/enterprise/steward-setup) for a complete walkthrough of deploying Steward in your VPC with Docker, Postgres, and optional S3/GCS body storage.
## Body storage configuration
Body storage is configured in the dashboard (Settings → Cloud Body Storage), not in Steward config. Connect your S3 or GCS bucket once, and Steward will write gzipped request/response bodies to it automatically. Majordomo's database contains only metadata — token counts, cost, latency, model name, and your custom tags.
See [Cloud Body Storage](/configuration/cloud-storage) for setup instructions.
## Checklist for enterprise reviews
Before a vendor security review, confirm:
* [ ] Steward is deployed inside your VPC (not using Managed deployment)
* [ ] Body storage is configured to your own S3/GCS bucket (or disabled if you don't need it)
* [ ] No `X-Majordomo-User-Id` or similar tags contain PII — use opaque identifiers
* [ ] Network egress from Steward is restricted to: LLM provider endpoints, your S3/GCS bucket, Majordomo metadata ingest endpoint
* [ ] Postgres is not publicly accessible
* [ ] You have a documented retention policy for the `llm_requests` table and your body storage bucket
# Evals
Source: https://docs.gomajordomo.com/guides/evals
Build evaluation sets from logged requests. Define scoring criteria. Run evals against any model before you ship.
Evals let you build a test suite from your real logged requests, define quality criteria, and run scored evaluations against any model — before deploying a change.
## The problem it solves
Every code change is tested before shipping. LLM outputs go to production with no systematic quality check. A prompt change, a model upgrade, or a configuration tweak can silently degrade output quality in ways that only show up in user complaints.
Evals close that gap. You define what "good" looks like for your specific use case, and run a scored test suite against any model or prompt configuration before it ships.
## Creating an eval set
In the dashboard, go to **Evals** and create an eval set:
1. **Name the set** — e.g., "Customer Support Quality", "Contract Extraction"
2. **Add items** — select logged requests to include, or filter by feature/date range
3. **Define criteria** — describe what a good response looks like for this use case
Eval sets are built from your real production requests — not synthetic examples. This means your tests reflect the actual distribution of inputs your users send.
## Running an eval
Select an eval set, choose the target model or prompt configuration, and run. The gateway:
1. Sends each item to the target model
2. Passes the original request, the original response, and the new response to an LLM judge
3. The judge scores each pair on your criteria (0–1) and returns reasoning
4. Results are aggregated into a pass rate and score distribution
## Reading results
| Metric | Description |
| ---------------------- | --------------------------------------------------------- |
| **Pass rate** | % of items above your threshold (default 0.8) |
| **Average score** | Mean quality score across all items |
| **Score distribution** | Histogram — tells you if failures are clustered or spread |
| **Low-scoring items** | The specific requests where the new model underperformed |
Low-scoring items are the most valuable output. Read through them to understand *why* the model failed — is it a specific input type, a prompt edge case, or a systematic quality difference?
## Using evals in your workflow
Treat eval runs the same way you treat test runs. Before merging a prompt change or model upgrade:
1. Run the relevant eval set against the change
2. Check the pass rate against your threshold
3. Review low-scoring items
4. Ship if it passes; iterate if it doesn't
The eval set grows over time as you add items from new edge cases or failures you find in production. The more representative the set, the more confident you can be in the results.
# Metadata Keys
Source: https://docs.gomajordomo.com/guides/metadata-keys
Discover, name, and activate metadata keys to power fast filtering and breakdowns in usage, Replay, and Evals.
Every request can include custom `X-Majordomo-*` headers. The gateway stores these as metadata on each request. The dashboard auto-discovers keys and lets you:
* Set a human‑friendly display name
* Mark keys Active to index them for fast filtering and breakdowns
Active keys are copied into an indexed JSONB column for `@>` queries and high‑cardinality analytics.
## Where to manage keys
* In the dashboard: Usage → Metadata Keys
You’ll see all keys discovered across your API keys, with request count, approximate unique values, and last seen.
## Making a key discoverable
Send any request with the header set, for example:
```http theme={null}
X-Majordomo-Feature: document-review
X-Majordomo-Team: legal
X-Majordomo-Environment: production
X-Majordomo-User-Id: user_123
```
The `X-Majordomo-` prefix is stripped in storage, so these become `Feature`, `Team`, `Environment`, and `User-Id`.
## Activating a key (indexing)
1. Open Usage → Metadata Keys
2. Locate the key and click “Active” to toggle it on
3. Optionally click the Display Name to set a friendlier label (e.g., `User ID`)
After activation, reports and filters that rely on `indexed_metadata` become fast and scalable for that key.
## Using active keys in filters
* Usage pages and Replay/Evals creation dialogs include metadata filters. Active keys appear in the pickers; values are matched using the indexed column.
## Best‑practice dimensions
* `Feature` — product feature or surface
* `Team` — owning or chargeback team
* `Environment` — prod/staging/dev
* `User-Id` — opaque end‑user identifier (do not place PII here)
* `User-Tier` — free/pro/enterprise
* `Experiment` — A/B test or model switch campaign
Start with what you actually query. You can add keys at any time — no schema change is required.
## Query examples
For indexed keys (fast):
```sql theme={null}
SELECT COUNT(*), SUM(total_cost)
FROM llm_requests
WHERE indexed_metadata @> '{"Feature": "document-review"}';
```
For non‑indexed keys (still works, slower):
```sql theme={null}
SELECT COUNT(*), SUM(total_cost)
FROM llm_requests
WHERE raw_metadata->>'Team' = 'platform';
```
See also: [Cost Attribution](/guides/cost-attribution).
## Notes
* Reserved headers are not treated as metadata: `X-Majordomo-Key`, `X-Majordomo-Provider`, `X-Majordomo-Provider-Alias`.
* Avoid PII in metadata values. Prefer opaque IDs.
# Replay
Source: https://docs.gomajordomo.com/guides/replay
Take real production traffic and replay it against a different model. Compare cost, latency, and output quality before you switch.
Replay lets you run a set of real production requests against a different model and compare the results side by side — cost, latency, and output quality — before committing to a switch.
## The problem it solves
A new model looks good in the playground. Benchmarks look promising. But you have no idea how it performs on *your* production traffic — the actual prompts, system prompts, conversation histories, and edge cases your users send.
Replay runs your real traffic against the candidate model. You get actual numbers on your workload, not synthetic benchmarks.
## Creating a replay run
In the dashboard, go to **Replay** and create a new run:
1. **Select a source** — choose an API key, date range, and optionally filter by feature or metadata
2. **Choose the target model** — the model you want to test
3. **Configure the judge** — optional LLM judge for automated quality scoring
4. **Run** — the gateway re-executes each selected request against the target model
## Reading results
For each replayed request you see:
| | Original | Replay |
| ------------- | -------- | ------------- |
| Model | `gpt-4o` | `gpt-4o-mini` |
| Input tokens | 1,240 | 1,240 |
| Output tokens | 384 | 312 |
| Cost | \$0.0142 | \$0.0008 |
| Latency | 2,100ms | 890ms |
| Quality score | — | 0.92 |
The quality score is produced by an LLM judge that compares the original and replay responses and returns a 0–1 equivalence score plus reasoning.
## Interpreting quality scores
A score of **0.9+** generally means the cheaper model produces outputs that are functionally equivalent for your use case. A score of **0.7–0.9** means similar outputs with some degradation — review the low-scoring requests manually to understand where the gaps are.
Low scores on specific request types often reveal where the cheaper model struggles (complex reasoning, long context, specific formatting). That tells you whether to switch fully, switch for a subset of traffic, or not switch at all.
## The decision framework
1. Run replay on a representative sample (500–1,000 requests is usually enough)
2. Check aggregate cost and latency savings
3. Review the quality score distribution
4. Read through 10–20 low-scoring pairs manually
5. If the failure modes are acceptable for your use case, switch
The goal isn't a perfect score — it's understanding *where* the model differs and deciding if those differences matter for your product.
# Set Up with Claude Code
Source: https://docs.gomajordomo.com/integrations/claude-code
Install the Majordomo skills so Claude Code wires the gateway and library into your codebase for you.
Majordomo ships two open-source [Claude Code skills](https://docs.claude.com/en/docs/claude-code/skills). Install them and Claude Code will connect your code to Majordomo the right way — correct base URL, headers, metadata, and agent-run tracking — without you memorizing any of it.
Route **any** client through the gateway — the OpenAI or Anthropic SDK, curl,
majordomo-llm, Pydantic AI, or Agno. Adds cost tracking, metadata attribution,
and agent-run waterfalls. Works with Managed Cloud, self-hosted Steward, or the
open-source gateway.
Use the `majordomo-llm` Python library directly — text, JSON, structured Pydantic
output, streaming, cost tracking, and provider cascade.
## Install
Skills live in a `skills/` directory in each project's repo. Copy the ones you want
into your personal skills folder (`~/.claude/skills/`) or a project's `.claude/skills/`.
```bash Personal (all projects) theme={null}
# Gateway skill — route any client through Majordomo
git clone https://github.com/go-majordomo/majordomo-gateway /tmp/mdm-gateway
cp -r /tmp/mdm-gateway/skills/majordomo-gateway ~/.claude/skills/
# Library skill — use majordomo-llm directly
git clone https://github.com/go-majordomo/majordomo-llm /tmp/mdm-llm
cp -r /tmp/mdm-llm/skills/majordomo-llm ~/.claude/skills/
```
```bash Project (this repo only) theme={null}
mkdir -p .claude/skills
git clone https://github.com/go-majordomo/majordomo-gateway /tmp/mdm-gateway
cp -r /tmp/mdm-gateway/skills/majordomo-gateway .claude/skills/
```
Restart Claude Code (or start a new session) so it picks up the skills.
## Use it
Just describe what you want in plain language — the skills load automatically when a
task matches. For example:
* *"Route these OpenAI calls through Majordomo and tag them by feature."*
* *"Wire my Pydantic AI agent through the gateway and group each conversation into one run."*
* *"Add cost tracking to this script with majordomo-llm."*
Claude Code will ask which gateway you're running (Cloud, self-hosted, or open source),
read the relevant environment variables, and write the integration.
The skills are open source under the MIT license — read or fork them in the
[majordomo-gateway](https://github.com/go-majordomo/majordomo-gateway) and
[majordomo-llm](https://github.com/go-majordomo/majordomo-llm) repos.
# Connect Your Code
Source: https://docs.gomajordomo.com/integrations/connect
Point any SDK at the Majordomo gateway with one config change.
Change the base URL. Add the `X-Majordomo-Key` header. Nothing else.
## OpenAI SDK
```python Python theme={null}
from openai import OpenAI
client = OpenAI(
base_url="https://gateway.gomajordomo.com/v1",
api_key="your-openai-api-key",
default_headers={"X-Majordomo-Key": "mdm_sk_your_key_here"},
)
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello!"}],
)
```
```javascript Node.js theme={null}
import OpenAI from 'openai';
const client = new OpenAI({
baseURL: 'https://gateway.gomajordomo.com/v1',
apiKey: process.env.OPENAI_API_KEY,
defaultHeaders: { 'X-Majordomo-Key': 'mdm_sk_your_key_here' },
});
const response = await client.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: 'Hello!' }],
});
```
```bash curl theme={null}
curl -X POST https://gateway.gomajordomo.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "X-Majordomo-Key: mdm_sk_your_key_here" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello!"}]}'
```
Streaming works unchanged — the gateway proxies the SSE stream transparently.
***
## Anthropic SDK
```python Python theme={null}
import anthropic
client = anthropic.Anthropic(
base_url="https://gateway.gomajordomo.com",
api_key="your-anthropic-api-key",
)
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello!"}],
extra_headers={"X-Majordomo-Key": "mdm_sk_your_key_here"},
)
```
```javascript Node.js theme={null}
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic({
baseURL: 'https://gateway.gomajordomo.com',
apiKey: process.env.ANTHROPIC_API_KEY,
});
const response = await client.messages.create(
{
model: 'claude-sonnet-4-6',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Hello!' }],
},
{ headers: { 'X-Majordomo-Key': 'mdm_sk_your_key_here' } },
);
```
```bash curl theme={null}
curl -X POST https://gateway.gomajordomo.com/v1/messages \
-H "Content-Type: application/json" \
-H "X-Majordomo-Key: mdm_sk_your_key_here" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello!"}]
}'
```
Prompt caching tokens (`cache_read_input_tokens`, `cache_creation_input_tokens`) are tracked and priced separately.
***
## Pydantic AI
No adapter needed — point Pydantic AI's underlying provider client at the gateway and add the `X-Majordomo-Key` header. The same pattern works for any framework that lets you configure the base URL and headers of its HTTP client.
```python theme={null}
from anthropic import AsyncAnthropic
from pydantic_ai import Agent
from pydantic_ai.models.anthropic import AnthropicModel
from pydantic_ai.providers.anthropic import AnthropicProvider
# Route the underlying client through the gateway
client = AsyncAnthropic(
base_url="https://gateway.gomajordomo.com",
api_key="your-anthropic-api-key",
default_headers={"X-Majordomo-Key": "mdm_sk_your_key_here"},
)
model = AnthropicModel(
"claude-sonnet-4-6",
provider=AnthropicProvider(anthropic_client=client),
)
agent = Agent(model=model, system_prompt="You are a helpful assistant.")
result = await agent.run("Summarize this document: ...")
```
Add per-request metadata by passing extra headers through the model settings — for example `AnthropicModelSettings(extra_headers={"X-Majordomo-Feature": "document-summarizer"})` on the `agent.run(...)` call.
***
## majordomo-llm
A unified async Python client for OpenAI, Anthropic, Gemini, and more — with built-in cost tracking, structured output, and multi-provider cascade. Route it through the gateway by passing `base_url` and `default_headers` to `get_llm_instance`.
```bash theme={null}
pip install majordomo-llm
```
```python theme={null}
from majordomo_llm import get_llm_instance, LLMCascade
GATEWAY = "https://gateway.gomajordomo.com"
HEADERS = {"X-Majordomo-Key": "mdm_sk_your_key_here"}
# Basic completion
llm = get_llm_instance(
"anthropic",
"claude-sonnet-4-6",
base_url=GATEWAY,
default_headers=HEADERS,
)
response = await llm.get_response("Summarize this document: ...")
print(response.content)
print(f"Cost: ${response.total_cost:.6f}")
# Structured output
from pydantic import BaseModel
class Sentiment(BaseModel):
label: str
score: float
result = await llm.get_structured_json_response(
response_model=Sentiment,
user_prompt="Analyze: 'This product is excellent'",
)
print(result.content.label) # "positive"
print(result.content.score) # 0.95
# Multi-provider cascade — falls back on failure
cascade = LLMCascade(
[("anthropic", "claude-sonnet-4-6"), ("openai", "gpt-4o")],
base_url=GATEWAY,
default_headers=HEADERS,
)
response = await cascade.get_response("Hello!")
```
[GitHub →](https://github.com/go-majordomo/majordomo-llm)
***
## Metadata tagging
Add any `X-Majordomo-*` header to tag requests for cost attribution. Works with all SDKs above.
```python theme={null}
# OpenAI
response = client.chat.completions.create(
model="gpt-4o",
messages=[...],
extra_headers={
"X-Majordomo-Feature": "document-review",
"X-Majordomo-Team": "legal",
"X-Majordomo-User-Id": "user_123",
},
)
# Anthropic — same pattern, add to extra_headers
response = client.messages.create(
...,
extra_headers={
"X-Majordomo-Key": "mdm_sk_your_key_here",
"X-Majordomo-Feature": "contract-analysis",
"X-Majordomo-Team": "legal",
},
)
```
See [Cost Attribution](/guides/cost-attribution) for how to query this data.
# Introduction
Source: https://docs.gomajordomo.com/introduction
Majordomo is the control plane for your AI stack — cost visibility, replay, and evals across every LLM request your team makes.
Majordomo sits between your application and your LLM providers. Every request is logged with model, tokens, cost, and latency. You get a dashboard, a query layer, and tools to test model changes before they ship — without touching your application code.
## What you get
Every request logged. Break down spend by team, feature, environment, or user with custom metadata headers.
Group the many LLM calls of one conversation or agent run into a single run — with a rolled-up cost and a nested waterfall of which step drove which calls.
Run real production traffic against a candidate model. Get actual cost, latency, and quality numbers on your workload before you switch.
Build test suites from logged requests. Define scoring criteria. Run scored evaluations against any model before a change ships.
Split live traffic across models by weight, compare cost, latency, and quality per arm, and promote the winner — no application code changes.
OpenAI, Anthropic, Gemini, Bedrock, and OpenAI-compatible providers (Fireworks, Together, DeepSeek) from a single endpoint.
## Two deployment modes
**Managed** — Majordomo runs Steward and the dashboard. Point your SDK at the gateway endpoint, create an API key, and you're logging requests within minutes. No infrastructure to operate.
**Self-hosted Steward** — You run Steward inside your own VPC. Your prompts and completions never leave your infrastructure. Majordomo receives only metadata — token counts, cost, latency, model name. The dashboard works identically. The right choice for teams with data residency requirements or enterprise customers who need to control where AI data is processed.
[How it works →](/architecture/how-it-works)
## How integration works
One config change. Everything else stays the same.
```python theme={null}
# Before
client = OpenAI(api_key="sk-...")
# After
client = OpenAI(
base_url="https://gateway.gomajordomo.com/v1",
api_key="sk-...",
default_headers={"X-Majordomo-Key": "mdm_sk_..."}
)
```
## Open source
Prefer to run a gateway yourself? `majordomo-gateway` is a standalone, self-hostable LLM gateway, open source under the MIT license — the same header conventions, priced and logged to your own Postgres, with nothing phoning home.
[GitHub →](https://github.com/go-majordomo/majordomo-gateway)
# Quick Start
Source: https://docs.gomajordomo.com/quickstart
Start logging LLM requests to production in minutes.
This guide covers the **Managed** setup — Majordomo runs the gateway, you point your SDK at it. If you need to run Steward inside your own VPC, see [Self-hosted Setup](/enterprise/steward-setup).
## 1. Create an account and API key
[Sign up at app.gomajordomo.com](https://app.gomajordomo.com). From the dashboard, go to **API Keys** and create your first key.
Your key has the format `mdm_sk_...`. Store it in your secrets manager — it's shown once at creation time.
## 2. Update your SDK configuration
Majordomo acts as a transparent proxy. Change the base URL, add one header. Your existing provider API key is passed through unchanged.
```python OpenAI (Python) theme={null}
import os
from openai import OpenAI
client = OpenAI(
base_url="https://gateway.gomajordomo.com/v1",
api_key=os.environ["OPENAI_API_KEY"],
default_headers={"X-Majordomo-Key": os.environ["MAJORDOMO_API_KEY"]},
)
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello"}],
)
```
```python Anthropic (Python) theme={null}
import os
import anthropic
client = anthropic.Anthropic(
base_url="https://gateway.gomajordomo.com",
api_key=os.environ["ANTHROPIC_API_KEY"],
)
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello"}],
extra_headers={"X-Majordomo-Key": os.environ["MAJORDOMO_API_KEY"]},
)
```
```javascript Node.js theme={null}
import OpenAI from 'openai';
const client = new OpenAI({
baseURL: 'https://gateway.gomajordomo.com/v1',
apiKey: process.env.OPENAI_API_KEY,
defaultHeaders: { 'X-Majordomo-Key': process.env.MAJORDOMO_API_KEY },
});
const response = await client.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: 'Hello' }],
});
```
Set the environment variable in your deployment environment:
```bash theme={null}
MAJORDOMO_API_KEY=mdm_sk_your_key_here
```
The gateway returns responses identically to calling the provider directly — streaming, function calling, and all provider-specific parameters are passed through unchanged.
## 3. Verify in the dashboard
Open the [Majordomo dashboard](https://app.gomajordomo.com). Your request appears with model, token counts, cost, and latency. No polling, no setup — the log is there as soon as the request completes.
From here you can manage API keys, tag requests for cost attribution, run replays against candidate models, and build eval sets from production traffic.
***
## Next steps
Tag requests with custom metadata and break down spend across any dimension.
Replay production traffic against a candidate model before committing to the change.
Run Steward in your own VPC. Prompts never leave your infrastructure.
Integration examples for every supported SDK, including Pydantic AI and majordomo-llm.
# Request Headers
Source: https://docs.gomajordomo.com/reference/headers
Complete reference for X-Majordomo-* headers.
All Majordomo-specific behavior is controlled via HTTP headers. Standard provider SDK headers (`Authorization`, `Content-Type`, etc.) pass through unchanged.
## Request headers
| Header | Required | Description |
| ---------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-Majordomo-Key` | Yes | Your Majordomo API key (`mdm_sk_...`). Identifies the account, associates the request with your usage log. |
| `X-Majordomo-Provider` | No | Explicit provider override: `openai`, `anthropic`, `gemini`, `bedrock`, `fireworks`, `together`, `deepseek`. Required for OpenAI-compatible providers (Fireworks, Together, DeepSeek), which share the OpenAI request path. If omitted, the gateway infers from the request path. |
### Provider inference
If `X-Majordomo-Provider` is not set, the gateway infers the provider:
| Path prefix | Inferred provider |
| ---------------------- | ----------------- |
| `/v1/chat/completions` | OpenAI |
| `/v1/messages` | Anthropic |
| `/v1beta/models` | Gemini |
Set the header explicitly when your path doesn't match the defaults, or when routing the same path to multiple providers.
## Metadata headers
Any header prefixed with `X-Majordomo-` is stored as metadata on the request log, except the reserved headers that carry dedicated behavior: `X-Majordomo-Key`, `X-Majordomo-Provider`, `X-Majordomo-Provider-Alias`, `X-Majordomo-Client`, and the [agent run tracking](#agent-run-tracking) headers (`X-Majordomo-Trace-Id`, `X-Majordomo-Span-Path`, `X-Majordomo-Span-Name`).
```http theme={null}
X-Majordomo-Feature: document-review
X-Majordomo-Team: legal
X-Majordomo-Environment: production
X-Majordomo-User-Id: user_123
```
Metadata is stored in the `raw_metadata` JSONB column on `llm_requests`. Keys can be promoted to the `indexed_metadata` column (GIN-indexed) via the dashboard for fast `@>` queries.
**Naming convention:** The `X-Majordomo-` prefix is stripped and the remainder is stored as-is. `X-Majordomo-Feature` becomes `Feature` in the metadata map.
**No schema changes required.** New keys are stored immediately. You can add metadata dimensions without touching the database.
### Recommended metadata dimensions
| Header | Example | Use for |
| ------------------------- | --------------------------- | --------------------------------- |
| `X-Majordomo-Feature` | `chat`, `summarizer` | Per product feature |
| `X-Majordomo-Team` | `platform`, `data` | Per team |
| `X-Majordomo-Environment` | `production`, `staging` | Per environment |
| `X-Majordomo-User-Id` | `user_abc123` | Per end user (opaque ID, not PII) |
| `X-Majordomo-User-Tier` | `free`, `pro`, `enterprise` | Per pricing tier |
| `X-Majordomo-Experiment` | `model-test-v2` | Per A/B test |
See [Cost Attribution](/guides/cost-attribution) for query examples.
## Agent run tracking
When one logical task — a conversation or an agent/workflow run — makes several LLM calls, these headers group those calls into a single **run** so the dashboard can show a rolled-up cost and a nested waterfall instead of unrelated requests. They are reserved (consumed by the gateway, not stored in `raw_metadata`) and stored as first-class columns on `llm_requests`.
| Header | Required | Description |
| ----------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-Majordomo-Trace-Id` | To join a run | One id per conversation / agent run. Generate it once at the start of the run and send it on every LLM call in that run (any opaque string). |
| `X-Majordomo-Span-Path` | No | `/`-joined names of the ancestor **steps** from the run root down to this call's parent, e.g. `planner/tool:search_db`. Omit to hang the call directly under the run root. `/` is the reserved separator — percent-encode a literal `/` inside a step name. |
| `X-Majordomo-Span-Name` | No | Label for this call in the waterfall. Defaults to the model name. |
**Graceful degradation:** a trace id alone gives a flat run rollup (all the run's calls + total cost); adding a span path gives the nested waterfall (which tool/agent step drove which calls, and what each cost). No SDK is required — any client that can set headers works.
```http theme={null}
X-Majordomo-Trace-Id: run_7f3a
X-Majordomo-Span-Path: planner/tool:search_db
X-Majordomo-Span-Name: summarize
```
See [Agent Run Tracking](/guides/agent-runs) for the full walkthrough.
## Full example
```python Python (OpenAI) theme={null}
from openai import OpenAI
client = OpenAI(
base_url="https://gateway.gomajordomo.com/v1",
api_key="your-openai-key",
default_headers={
"X-Majordomo-Key": "mdm_sk_your_key_here",
"X-Majordomo-Feature": "document-review",
"X-Majordomo-Team": "legal",
"X-Majordomo-Environment": "production",
}
)
```
```python Python (Anthropic) theme={null}
import anthropic
client = anthropic.Anthropic(
base_url="https://gateway.gomajordomo.com",
api_key="your-anthropic-key",
)
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello"}],
extra_headers={
"X-Majordomo-Key": "mdm_sk_your_key_here",
"X-Majordomo-Feature": "support-chat",
"X-Majordomo-User-Id": "user_123",
}
)
```
```bash curl theme={null}
curl -X POST https://gateway.gomajordomo.com/v1/chat/completions \
-H "Authorization: Bearer your-openai-key" \
-H "X-Majordomo-Key: mdm_sk_your_key_here" \
-H "X-Majordomo-Feature: chat" \
-H "X-Majordomo-Team: product" \
-H "Content-Type: application/json" \
-d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello"}]}'
```
## Header forwarding
Headers prefixed with `X-Majordomo-` are **not** forwarded to upstream providers. They are consumed by the gateway and stripped before the request is proxied. All other headers (including custom `X-*` headers your provider supports) are forwarded as-is.
# Data Model
Source: https://docs.gomajordomo.com/reference/schema
Reference for core tables written by Steward and queried by the dashboard.
This page documents the columns you’ll see when querying usage directly. Steward writes locally first and batches metadata to Butler; the dashboard reads from Butler’s database.
Where data lives
* Steward (gateway) writes request rows immediately to its local DB and uploads bodies to your S3/GCS when configured.
* Butler (control plane) ingests usage/metadata in batches from Steward and serves the dashboard/API.
* The Web dashboard queries Butler. If you query directly, prefer Butler’s DB for analytics consistency.
## llm\_requests
One row per proxied request.
| Column | Type | Description |
| ------------------------ | --------------- | ------------------------------------------------------------------------------------------------------ |
| `id` | `uuid` | Request id. |
| `majordomo_api_key_id` | `uuid` | Owning Majordomo API key. |
| `provider_api_key_hash` | `varchar(64)` | Hash of upstream provider Authorization header. |
| `provider_api_key_alias` | `varchar(255)` | Optional alias from `X-Majordomo-Provider-Alias`. |
| `provider` | `varchar(100)` | `openai`, `anthropic`, `gemini`, `bedrock`, `fireworks`, `together`, or `deepseek`. |
| `model` | `varchar(100)` | Model name as reported (or translated). |
| `request_path` | `text` | Upstream path (e.g., `/v1/chat/completions`). |
| `request_method` | `text` | HTTP method. |
| `requested_at` | `timestamptz` | Timestamp when request was received. |
| `responded_at` | `timestamptz` | Timestamp when response was fully sent. |
| `response_time_ms` | `int` | Wall-clock response time. |
| `input_tokens` | `int` | Count parsed from provider response. |
| `output_tokens` | `int` | Count parsed from provider response. |
| `cached_tokens` | `int` | Prompt caching read tokens (if applicable). |
| `cache_creation_tokens` | `int` | Tokens charged to create cache entries. |
| `input_cost` | `numeric(12,8)` | Calculated cost for input tokens. |
| `output_cost` | `numeric(12,8)` | Calculated cost for output tokens. |
| `total_cost` | `numeric(12,8)` | Sum of input/output (and cache) costs. |
| `status_code` | `int` | Upstream HTTP status. |
| `error_message` | `text` | Truncated error body when status ≥ 400. |
| `raw_metadata` | `jsonb` | All custom headers (`X-Majordomo-*`, minus reserved) without indexing. |
| `indexed_metadata` | `jsonb` | Subset of active keys copied for fast `@>` queries (GIN index). |
| `request_body` | `text` | Optional local body copy when Postgres body storage is enabled. |
| `response_body` | `text` | Optional local body copy when Postgres body storage is enabled. |
| `body_s3_key` | `text` | Object key when uploaded to S3/GCS via personal/org config. |
| `model_alias_found` | `bool` | True if pricing alias resolved for the model. |
| `org_id` | `uuid` | Owning org (shadow for filtering/joins). |
| `created_at` | `timestamptz` | Row creation timestamp. |
| `synced_to_butler` | `bool` | Steward-only: batched to Butler yet. |
| `trace_id` | `text` | [Agent run](/guides/agent-runs) id from `X-Majordomo-Trace-Id`. `NULL` for standalone requests. |
| `span_path` | `text` | Canonical `/`-joined ancestor step names from `X-Majordomo-Span-Path` (e.g. `planner/tool:search_db`). |
| `span_name` | `text` | Label for this call in the waterfall (`X-Majordomo-Span-Name`, defaults to the model). |
| `span_id` | `uuid` | This call's span id (defaults to the request id). |
| `parent_span_id` | `uuid` | Deterministic id of the interior step named by `span_path`, derived from `(trace_id, span_path)`. |
Indexes: by `(majordomo_api_key_id, requested_at DESC)`, a GIN index on `indexed_metadata`, and a partial index on `trace_id` (where not null) for run lookups.
## llm\_requests\_metadata\_keys
Per-API-key registry of discovered metadata keys and their indexing state.
| Column | Type | Description |
| ---------------------- | -------------- | ------------------------------------------------------------ |
| `majordomo_api_key_id` | `uuid` | API key owner. Part of PK. |
| `key_name` | `varchar(255)` | Stored name (prefix `X-Majordomo-` is stripped). Part of PK. |
| `display_name` | `varchar(255)` | UI label. |
| `key_type` | `varchar(50)` | Semantic hint, default `string`. |
| `is_required` | `bool` | Reserved for future validation. |
| `is_active` | `bool` | If true, key is copied into `indexed_metadata`. |
| `activated_at` | `timestamptz` | When indexing was enabled. |
| `request_count` | `bigint` | How many requests carried this key. |
| `last_seen_at` | `timestamptz` | Most recent occurrence. |
| `hll_state` | `bytea` | HyperLogLog state. |
| `approx_cardinality` | `int` | Approx unique values for the key. |
| `hll_updated_at` | `timestamptz` | Last HLL update. |
| `created_at` | `timestamptz` | Row creation timestamp. |
Index: `is_active` per API key for fast lookups.
## Notes
* Reserved headers that do not enter `raw_metadata`: `X-Majordomo-Key`, `X-Majordomo-Provider`, `X-Majordomo-Provider-Alias`, `X-Majordomo-Client`, `X-Majordomo-Trace-Id`, `X-Majordomo-Span-Path`, `X-Majordomo-Span-Name`.
* Bodies in Postgres are off by default; prefer S3/GCS via [Cloud Body Storage](/configuration/cloud-storage).
***
## SQL primer
For end‑to‑end examples, see [Cost Attribution](/guides/cost-attribution). A few quick patterns:
```sql theme={null}
-- Last 7 days by day
SELECT date_trunc('day', requested_at) AS day,
COUNT(*) AS requests,
SUM(total_cost) AS total_cost
FROM llm_requests
WHERE requested_at >= now() - interval '7 days'
GROUP BY 1
ORDER BY 1;
```
```sql theme={null}
-- Filter by an indexed metadata key (fast)
SELECT COUNT(*), SUM(total_cost)
FROM llm_requests
WHERE indexed_metadata @> '{"Feature": "document-review"}';
```
```sql theme={null}
-- Filter by a non‑indexed key (works, slower)
SELECT COUNT(*), SUM(total_cost)
FROM llm_requests
WHERE raw_metadata->>'Team' = 'platform';
```