{"slug":"aperture-by-tailscale-configuration","title":"Aperture by Tailscale configuration","tags":["tailscale"],"agent_summary":"Last validated: Apr 9, 2026","trigger_phrases":[],"runnable":false,"markdown":"\r\n# Aperture by Tailscale configuration\r\n\r\nLast validated: Apr 9, 2026\r\n\r\nAperture by Tailscaleis currently [in alpha](https://tailscale.com/docs/reference/tailscale-release-stages#alpha).\r\n\r\nDuring the alpha testing period, Aperture by Tailscale is available at no additional cost across [all Tailscale plans](https://tailscale.com/pricing). Request access at [`aperture.tailscale.com`](https://aperture.tailscale.com/). Aperture by Tailscale comes with six free users. Contact Tailscale for pricing if you need more than six users.\r\n\r\n[Aperture by Tailscale](https://tailscale.com/docs/aperture) uses a JSON configuration to specify LLM providers, access control policies, and optional integrations. The Aperture configuration controls which models are available, how requests authenticate with upstream providers, and who can access what. Admins can edit the configuration from the **Settings** page of the [Aperture dashboard](https://tailscale.com/docs/aperture/reference/dashboard) using the **Visual editor** (default) or the **JSON editor**.\r\n\r\nThis page is part of the [Aperture reference](https://tailscale.com/docs/aperture/reference) documentation.\r\n\r\n## [Minimal configuration](https://tailscale.com/docs/aperture/configuration\\#minimal-configuration)\r\n\r\nA minimal configuration requires at least one provider with a base URL and at least one model. The following example shows a minimal configuration:\r\n\r\n```json\r\n{\r\n  \"providers\": {\r\n    \"anthropic\": {\r\n      \"baseurl\": \"https://api.anthropic.com\",\r\n      \"apikey\": \"YOUR_ANTHROPIC_API_KEY\",\r\n      \"models\": [\\\r\n        \"claude-sonnet-4-5\",\\\r\n        \"claude-opus-4-5\",\\\r\n      ],\r\n      \"authorization\": \"x-api-key\",\r\n      \"compatibility\": {\r\n        \"anthropic_messages\": true,\r\n      }\r\n    }\r\n  }\r\n}\r\n```\r\n\r\nIf you omit `apikey`, Aperture logs a warning at startup but continues to run. Most providers require an API key for authentication, so add one unless your provider handles authentication differently.\r\n\r\nThe `apikey` field requires an API key from the provider's developer platform. Consumer and business subscription plans (such as [Claude Pro or Claude Max](https://www.anthropic.com/pricing), [ChatGPT Plus, Pro, or Team](https://openai.com/chatgpt/pricing), or [Gemini Advanced](https://gemini.google.com/)) do not provide API keys and are not compatible with Aperture.\r\n\r\n## [Default configuration](https://tailscale.com/docs/aperture/configuration\\#default-configuration)\r\n\r\nNew Aperture instances use a default configuration that includes OpenAI and Anthropic providers with common models. The default grants all users access to all models. The following shows the default configuration:\r\n\r\n```json\r\n{\r\n    // The grants section uses the Tailscale ACL grant structure.\r\n    \"grants\": [\\\r\n        // Grant admin access (permission to see the settings and all other\\\r\n        // users in the dashboard).\\\r\n        {\\\r\n            \"src\": [\\\r\n                // Explicitly identify certain users by their Tailscale login.\\\r\n                \"example-user@example.com\",\\\r\n\\\r\n                // Grant admin access to everyone by default.\\\r\n                // Remove this after you've configured explicit admin\\\r\n                // access for yourself.\\\r\n                // BE CAREFUL! If you remove this without granting explicit\\\r\n                // admin access to yourself, you'll lose your ability\\\r\n                // to edit this file.\\\r\n                \"*\",\\\r\n            ],\\\r\n            \"app\": {\\\r\n                \"tailscale.com/cap/aperture\": [\\\r\n                    { \"role\": \"admin\" },\\\r\n                ],\\\r\n            },\\\r\n        },\\\r\n\\\r\n        // Every user who can access Aperture gets at least user-level access.\\\r\n        // Remove this and Aperture denies access entirely by default.\\\r\n        // Admin access in a separate grant takes precedence over this section.\\\r\n        {\\\r\n            \"src\": [\"*\"],\\\r\n            \"app\": {\\\r\n                \"tailscale.com/cap/aperture\": [\\\r\n                    { \"role\": \"user\" },\\\r\n                ],\\\r\n            },\\\r\n        },\\\r\n\\\r\n        // Default: allow all users to access all models from all providers.\\\r\n        // Without this grant, users can't access any models (deny by default).\\\r\n        {\\\r\n            \"src\": [\"*\"],\\\r\n            \"app\": {\\\r\n                \"tailscale.com/cap/aperture\": [\\\r\n                    { \"models\": \"**\" },\\\r\n                ],\\\r\n            },\\\r\n        },\\\r\n\\\r\n        // This example hook sends traffic to Oso if it matches certain\\\r\n        // parameters. Configure Oso in the \"hooks\" section for this to work.\\\r\n        {\\\r\n            \"src\": [\\\r\n                // No users by default. Try \"*\" to capture everyone's traffic.\\\r\n            ],\\\r\n            \"app\": {\\\r\n                \"tailscale.com/cap/aperture\": [\\\r\n                    {\\\r\n                        \"send_hooks\": [\\\r\n                            {\\\r\n                                \"name\": \"oso\",\\\r\n                                // Capturing only tool calls\\\r\n                                \"events\": [\"tool_call_entire_request\"],\\\r\n                                \"send\": [\"user_message\", \"tools\", \"request_body\", \"response_body\"],\\\r\n                            },\\\r\n                        ],\\\r\n                    },\\\r\n                ],\\\r\n            },\\\r\n        },\\\r\n    ],\r\n\r\n    // Configure your LLM backends here.\r\n    // Fill your API keys in below to share these providers with your team.\r\n    // There's no limit to the number of providers you can configure.\r\n    \"providers\": {\r\n        \"openai\": {\r\n            \"baseurl\": \"https://api.openai.com\",\r\n            \"name\": \"OpenAI\",\r\n            \"apikey\": \"YOUR_OPENAI_API_KEY\",\r\n            \"models\": [\\\r\n                \"gpt-5\",\\\r\n                \"gpt-5-mini\",\\\r\n                \"gpt-5-nano\",\\\r\n                \"gpt-4.1\",\\\r\n                \"gpt-4.1-nano\",\\\r\n                \"gpt-5.1-codex\",\\\r\n                \"gpt-5.1-codex-max\",\\\r\n            ],\r\n            \"compatibility\": {\r\n                \"openai_chat\": true,\r\n                \"openai_responses\": true,\r\n                \"anthropic_messages\": false,\r\n            },\r\n        },\r\n        \"anthropic\": {\r\n            \"baseurl\": \"https://api.anthropic.com\",\r\n            \"name\": \"Anthropic\",\r\n            \"apikey\": \"YOUR_ANTHROPIC_API_KEY\",\r\n            \"models\": [\\\r\n                \"claude-sonnet-4-5\",\\\r\n                \"claude-sonnet-4-5-20250929\",\\\r\n                \"claude-haiku-4-5\",\\\r\n                \"claude-haiku-4-5-20251001\",\\\r\n                \"claude-opus-4-5\",\\\r\n                \"claude-opus-4-5-20251101\",\\\r\n            ],\r\n            \"compatibility\": {\r\n                \"openai_chat\": false,\r\n                \"openai_responses\": false,\r\n                \"anthropic_messages\": true,\r\n            },\r\n        },\r\n    },\r\n\r\n    // Hooks are configured API endpoints that Aperture calls under certain\r\n    // conditions. The conditions themselves are configured in the\r\n    // \"grants\" section.\r\n    \"hooks\": {\r\n        \"oso\": {\r\n            \"url\":    \"https://api.osohq.com/api/agents/v1/model-request\",\r\n            \"apikey\": \"YOUR_OSO_API_KEY\",\r\n        },\r\n    },\r\n}\r\n```\r\n\r\n## [Configuration reference](https://tailscale.com/docs/aperture/configuration\\#configuration-reference)\r\n\r\nThe Aperture configuration contains several top-level sections that control different aspects of Aperture's behavior. The following table describes the available top-level sections:\r\n\r\n| Section | Required | Description |\r\n| --- | --- | --- |\r\n| `providers` | Yes | Map of LLM provider configurations. |\r\n| `grants` | No | Access control policies for users, models, and quotas. Uses the Tailscale grant structure. |\r\n| `quotas` | No | Dollar-based spending limits using token buckets. |\r\n| `hooks` | No | Webhook endpoint configurations. |\r\n| `exporters` | No | LLM session log export configuration. Currently supports S3-compatible storage. |\r\n| `auto_cost_basis` | No | Boolean (default `true`). When `true`, Aperture infers `cost_basis` from a provider's compatibility flags when no explicit `cost_basis` is set. Set to `false` to disable auto-inference, so only providers with an explicit `cost_basis` produce cost estimates. |\r\n\r\n### [`providers`](https://tailscale.com/docs/aperture/configuration\\#providers)\r\n\r\nThe `providers` section specifies the LLM providers to which Aperture routes requests. A unique string key identifies each provider. The following example shows the basic structure:\r\n\r\n```json\r\n{\r\n  \"providers\": {\r\n    \"openai\": { ... },\r\n    \"anthropic\": { ... },\r\n    \"private\": { ... }\r\n  }\r\n}\r\n```\r\n\r\nEach provider configuration accepts the following fields:\r\n\r\n| Field | Type | Required | Default | Description |\r\n| --- | --- | --- | --- | --- |\r\n| `baseurl` | string | Yes | N/A | Base URL for the provider's API. |\r\n| `models` | array | Yes | N/A | List of model IDs available from this provider. |\r\n| `apikey` | string | No | `\"\"` | API key for authentication. |\r\n| `authorization` | string | No | `\"bearer\"` | Authorization header type. |\r\n| `name` | string | No | `\"\"` | Display name for the UI. |\r\n| `description` | string | No | `\"\"` | Description shown in the UI. |\r\n| `compatibility` | object | No | `openai_chat` enabled, all others disabled | API compatibility flags. Refer to the [provider compatibility reference](https://tailscale.com/docs/aperture/provider-compatibility) for details. |\r\n| `cost_basis` | string | No | Auto-inferred | Override the pricing service used for cost estimation. Refer to the [provider compatibility reference](https://tailscale.com/docs/aperture/provider-compatibility) for valid values. |\r\n| `preference` | int | No | `0` | Routing priority when a model is available from multiple providers. Higher value wins. |\r\n| `disabled` | bool | No | `false` | Deactivates the provider without removing its configuration. Disabled providers are excluded from routing and `/v1/models`. |\r\n| `add_headers` | array | No | `[]` | Custom headers added to every upstream request for this provider. Each entry uses `\"Header-Name: value\"` format. |\r\n| `model_cost_map` | array | No | `[]` | Map unknown model names to known models for pricing. Refer to [model cost map](https://tailscale.com/docs/aperture/provider-compatibility#model-cost-map). |\r\n\r\nThe `authorization` field is not required for all providers. For example, Vertex AI uses a service account key file instead of an API key (prefixed with `keyfile::`). Refer to [set up a Vertex AI provider](https://tailscale.com/docs/aperture/how-to/use-vertex-ai) for step-by-step configuration instructions.\r\n\r\n#### [Authorization types](https://tailscale.com/docs/aperture/configuration\\#authorization-types)\r\n\r\nDifferent providers require different authorization header formats. The `authorization` field supports `bearer` (default, used by OpenAI and most providers), `x-api-key` (Anthropic), and `x-goog-api-key` (Google Gemini). For the full authorization types table and provider-specific details, refer to the [provider compatibility reference](https://tailscale.com/docs/aperture/provider-compatibility).\r\n\r\n#### [Provider compatibility](https://tailscale.com/docs/aperture/configuration\\#provider-compatibility)\r\n\r\nThe `compatibility` object specifies which API formats the provider supports. This determines which endpoints Aperture exposes for the provider's models. Flags include `openai_chat` (default enabled), `openai_responses`, `anthropic_messages`, `gemini_generate_content`, `bedrock_model_invoke`, and others. For the complete compatibility flags table, provider matrix, and configuration examples for each provider, refer to the [provider compatibility reference](https://tailscale.com/docs/aperture/provider-compatibility).\r\n\r\n#### [Provider examples](https://tailscale.com/docs/aperture/configuration\\#provider-examples)\r\n\r\nFor complete configuration examples for each supported provider (OpenAI, Anthropic, Google Gemini, Vertex AI, Amazon Bedrock, OpenRouter, and self-hosted LLMs), refer to the [provider compatibility reference](https://tailscale.com/docs/aperture/provider-compatibility).\r\n\r\n### [Pricing and cost estimation](https://tailscale.com/docs/aperture/configuration\\#pricing-and-cost-estimation)\r\n\r\nAperture estimates the dollar cost of every LLM request. Cost estimates power quotas, hook metadata, and the per-model pricing shown in the Aperture dashboard. Aperture auto-infers pricing for known providers based on compatibility flags, but you can override this with the `cost_basis` and `model_cost_map` provider fields. For the full list of `cost_basis` values, model cost mapping syntax, and per-provider pricing details, refer to the [provider compatibility reference](https://tailscale.com/docs/aperture/provider-compatibility).\r\n\r\nAperture surfaces cost data in several places:\r\n\r\n- **Models** page of the Aperture dashboard: Each model shows per-million-token pricing (input/output) with a tooltip that includes cache, reasoning, image, and web search rates.\r\n- **CSV export**: The **Adoption** page's **Download CSV** button exports usage data including token counts per model, user, and date.\r\n- **Hooks**: Include `\"estimated_cost\"` in a hook's `send` array to receive dollar cost, cost basis, and token usage with every hook call. Refer to [hook send types](https://tailscale.com/docs/aperture/configuration#hook-send-types) for details.\r\n\r\n### [`grants`](https://tailscale.com/docs/aperture/configuration\\#grants)\r\n\r\nThe `grants` section specifies access control policies that determine which users can access which models, what hooks fire, and which quotas apply. Grants use the [Tailscale grant structure](https://tailscale.com/docs/features/access-control/grants/grants-app-capabilities) with capabilities scoped under `\"tailscale.com/cap/aperture\"`. Aperture is deny-by-default: without a matching grant, a user cannot access any models.\r\n\r\nFor step-by-step instructions on configuring access, refer to [Control model access](https://tailscale.com/docs/aperture/how-to/grant-model-access) and [Set up admin access](https://tailscale.com/docs/aperture/how-to/set-up-admin-access).\r\n\r\nThe `grants` section replaces the deprecated `temp_grants` syntax with a new structure. The `temp_grants` syntax still works but is not recommended for new configurations.\r\n\r\nYou can specify grants in two places:\r\n\r\n1. **Aperture configuration**: In the `\"grants\"` array (described below).\r\n2. **Tailnet policy file**: As app capabilities under `\"tailscale.com/cap/aperture\"`, delivered to Aperture automatically through the Tailscale coordination server.\r\n\r\nAperture merges grants from both sources additively. Roles escalate (user to admin) but never downgrade.\r\n\r\n#### [Basic structure](https://tailscale.com/docs/aperture/configuration\\#basic-structure)\r\n\r\nA grant specifies a source (`src`) and a set of app capabilities:\r\n\r\n```json\r\n\"grants\": [\\\r\n  {\\\r\n    \"src\": [\"*\"],               // who this grant applies to\\\r\n    \"app\": {\\\r\n      \"tailscale.com/cap/aperture\": [\\\r\n        // array of individual capabilities\\\r\n        { \"models\": \"**\" },     // allow access to all models\\\r\n      ],\\\r\n    },\\\r\n  },\\\r\n]\r\n```\r\n\r\n#### [Source match (`src`)](https://tailscale.com/docs/aperture/configuration\\#source-match-src)\r\n\r\nThe `src` field determines which users a grant applies to:\r\n\r\n- `\"*\"`: Matches everyone.\r\n- `\"alice@example.com\"`: Matches a specific Tailscale login name.\r\n- `\"(loopback)\"`: Matches local/loopback requests (useful for development).\r\n\r\n#### [Model access](https://tailscale.com/docs/aperture/configuration\\#model-access)\r\n\r\nEach `models` field accepts a single glob pattern using fully-qualified `provider/model` format. To match multiple providers, use separate grant entries.\r\n\r\n| Pattern | Matches |\r\n| --- | --- |\r\n| `\"**\"` | All models from all providers |\r\n| `\"anthropic/**\"` | All Anthropic models |\r\n| `\"openai/gpt-5\"` | Exactly `openai/gpt-5` |\r\n| `\"*/claude-sonnet*\"` | Any `claude-sonnet*` model from any single provider |\r\n| `\"aperture-*/**\"` | Any model from a provider whose name starts with `aperture-` |\r\n\r\n`*` matches a single path segment. `**` matches zero or more segments.\r\n\r\nA grant with no `models` field is \"floating,\" meaning it applies globally (useful for hooks and quotas that apply regardless of model).\r\n\r\n#### [Role assignment](https://tailscale.com/docs/aperture/configuration\\#role-assignment)\r\n\r\nRoles determine a user's permission level:\r\n\r\n```json\r\n{ \"role\": \"admin\" }   // full admin access\r\n{ \"role\": \"user\" }    // standard user access\r\n```\r\n\r\nWithout a role grant, the user cannot access Aperture. If multiple grants match a given user, the highest-permissioned role (admin) wins.\r\n\r\n#### [MCP access](https://tailscale.com/docs/aperture/configuration\\#mcp-access)\r\n\r\nAperture's MCP server support is experimental. The MCP grants syntax may change.\r\n\r\nGrant access to registered MCP items in the same way as models:\r\n\r\n```json\r\n{\r\n  \"mcp_tools\": \"local/*\",       // tools from the \"local\" MCP server\r\n  \"mcp_resources\": \"**\",        // all resources from all servers\r\n  \"mcp_templates\": \"remote/*\",  // templates from \"remote\" server\r\n}\r\n```\r\n\r\n#### [Additional capability fields](https://tailscale.com/docs/aperture/configuration\\#additional-capability-fields)\r\n\r\nEach capability object in the `tailscale.com/cap/aperture` array can also include these fields:\r\n\r\n| Field | Type | Description |\r\n| --- | --- | --- |\r\n| `add_headers` | array | Custom headers prepended to upstream requests when this grant matches. Each entry uses `\"Header-Name: value\"` format. |\r\n| `enable_chat_ui` | bool | Grants access to the chat UI feature. |\r\n| `read_metrics` | bool | Grants access to the `/metrics` Prometheus endpoint. |\r\n\r\n#### [Custom app capabilities](https://tailscale.com/docs/aperture/configuration\\#custom-app-capabilities)\r\n\r\nGrants can include capability keys beyond `tailscale.com/cap/aperture`. Aperture passes these through to hooks when you include the `\"grants\"` send type:\r\n\r\n```json\r\n{\r\n  \"src\": [\"admin@example.com\"],\r\n  \"app\": {\r\n    \"tailscale.com/cap/aperture\": [\\\r\n      { \"role\": \"admin\" },\\\r\n      { \"models\": \"**\" },\\\r\n    ],\r\n    // Custom capability — forwarded to hooks via \"grants\" send type\r\n    \"mycompany.com/cap/policy\": [\\\r\n      { \"tier\": \"enterprise\", \"department\": \"engineering\" },\\\r\n    ],\r\n  },\r\n},\r\n```\r\n\r\nWhen a hook includes `\"grants\"` in its `send` array, these custom capabilities appear in the hook metadata. External systems can use these capabilities to make authorization decisions.\r\n\r\n#### [Grants from the tailnet policy file](https://tailscale.com/docs/aperture/configuration\\#grants-from-the-tailnet-policy-file)\r\n\r\nSpecify grants directly in the tailnet policy file to use Tailscale's groups, tags, and device postures. This is the recommended approach for organizations that already manage access through Tailscale:\r\n\r\n```json\r\n// In the tailnet policy file (not the Aperture config):\r\n\"grants\": [\\\r\n  {\\\r\n    \"src\": [\"group:engineering\"],\\\r\n    \"dst\": [\"tag:aperture\"],\\\r\n    \"app\": {\\\r\n      \"tailscale.com/cap/aperture\": [\\\r\n        { \"models\": \"**\" },\\\r\n        { \"role\": \"user\" },\\\r\n      ],\\\r\n    },\\\r\n  },\\\r\n  {\\\r\n    \"src\": [\"group:ml-team\"],\\\r\n    \"dst\": [\"tag:aperture\"],\\\r\n    \"app\": {\\\r\n      \"tailscale.com/cap/aperture\": [\\\r\n        { \"models\": \"**\" },\\\r\n        { \"role\": \"admin\" },\\\r\n      ],\\\r\n    },\\\r\n  },\\\r\n]\r\n```\r\n\r\nAperture receives these capabilities from the Tailscale peer information at request time and merges them with any grants specified in the Aperture configuration.\r\n\r\nYou can also scope grants based on Tailscale device postures. For example, grant access to different models or quotas, or apply different hooks, based on whether a user is on a managed corporate laptop versus an unmanaged VM (virtual machine).\r\n\r\nGroups (for example, `group:engineering`) aren't available for grants specified in the Aperture configuration. The Tailscale coordination server tracks group membership information and does not share it with Aperture. Grants specified in the Aperture configuration can match on individual login names or tags.\r\n\r\n### [`quotas`](https://tailscale.com/docs/aperture/configuration\\#quotas)\r\n\r\nThe `quotas` section specifies dollar-based spending limits using token buckets. Each bucket has a capacity (maximum balance) and a refill rate. When a request's estimated cost would bring a bucket below zero, Aperture rejects the request with HTTP 429.\r\n\r\nFor step-by-step instructions, refer to [Set per-user spending limits](https://tailscale.com/docs/aperture/how-to/set-per-user-spending-limits) and [Set a team-wide budget](https://tailscale.com/docs/aperture/how-to/set-team-budget). To check and refill budgets, refer to [Check and refill budgets](https://tailscale.com/docs/aperture/how-to/check-and-refill-budgets).\r\n\r\n```json\r\n\"quotas\": {\r\n  // Per-user daily budget: refills $5/day, can burst up to $10\r\n  \"daily:<user>\": {\r\n    \"capacity\": \"$10.00\",\r\n    \"rate\": \"$5.00/day\",\r\n    \"on_exceed\": \"reject\",\r\n  },\r\n\r\n  // Shared pool across all users who reference it\r\n  \"eng-team-pool\": {\r\n    \"capacity\": \"$100.00\",\r\n    \"rate\": \"$100.00/day\",\r\n    \"on_exceed\": \"reject\",\r\n  },\r\n\r\n  // Per-user limit for expensive models\r\n  \"opus:<user>\": {\r\n    \"capacity\": \"$5.00\",\r\n    \"rate\": \"$2.50/day\",\r\n    \"on_exceed\": \"reject\",\r\n  },\r\n}\r\n```\r\n\r\nEach quota accepts the following fields:\r\n\r\n| Field | Format | Description |\r\n| --- | --- | --- |\r\n| `capacity` | `\"$<amount>\"` | Maximum balance the bucket can hold. Also the starting balance. |\r\n| `rate` | `\"$<amount>/<unit>\"` | How fast the bucket refills. Units: `min`, `hour`, `day`, `week`, `month` (30 days). |\r\n| `on_exceed` | `\"reject\"` | Action when the bucket is empty. The supported value is `\"reject\"` (HTTP 429). |\r\n\r\n#### [Template variables](https://tailscale.com/docs/aperture/configuration\\#template-variables)\r\n\r\nQuota names can include template variables that expand at request time:\r\n\r\n| Template | Expands to | Example |\r\n| --- | --- | --- |\r\n| `<user>` | Caller's Tailscale login name or tag combination | `daily:<user>` expands to `daily:alice@example.com` |\r\n| `<node>` | Caller's node ID (a distinct quota for each node, even if nodes share the same user or tag) | `device:<node>` expands to `device:nXXXXXXXXXCNTRL` |\r\n\r\nQuotas without a template variable (for example, `eng-team-pool`) create a single shared bucket.\r\n\r\n#### [Attach quotas to grants](https://tailscale.com/docs/aperture/configuration\\#attach-quotas-to-grants)\r\n\r\nQuotas take effect only when attached to grants. A quota specified in the `quotas` section does nothing until a grant references it. When a request matches a grant, all quotas listed in that grant are charged at the same time:\r\n\r\n```json\r\n\"grants\": [\\\r\n  {\\\r\n    \"src\": [\"*\"],\\\r\n    \"app\": {\\\r\n      \"tailscale.com/cap/aperture\": [\\\r\n        {\\\r\n          \"models\": \"**\",\\\r\n          \"quotas\": [\\\r\n            {\"bucket\": \"daily:<user>\"},\\\r\n            {\"bucket\": \"eng-team-pool\"},\\\r\n          ],\\\r\n        },\\\r\n      ],\\\r\n    },\\\r\n  },\\\r\n]\r\n```\r\n\r\n#### [How multiple quotas interact](https://tailscale.com/docs/aperture/configuration\\#how-multiple-quotas-interact)\r\n\r\nWhen a grant references multiple quota buckets, all buckets must have a positive balance for the request to proceed. If any single bucket is exhausted, Aperture rejects the request, even if other buckets have remaining balance.\r\n\r\nAfter the response completes, Aperture deducts the estimated cost from every referenced bucket at the same time.\r\n\r\nA request can match multiple grants, each with their own quotas. Aperture collects and enforces all matching quotas together.\r\n\r\n#### [Quota examples](https://tailscale.com/docs/aperture/configuration\\#quota-examples)\r\n\r\nThe following examples show common quota configurations.\r\n\r\n##### [Per-user quota](https://tailscale.com/docs/aperture/configuration\\#per-user-quota)\r\n\r\nEach user gets their own bucket with independent capacity and refill rate:\r\n\r\n```json\r\n\"quotas\": {\r\n  \"daily:<user>\": {\r\n    \"capacity\": \"$10.00\",\r\n    \"rate\": \"$5.00/day\",\r\n    \"on_exceed\": \"reject\",\r\n  },\r\n},\r\n\"grants\": [\\\r\n  {\\\r\n    \"src\": [\"*\"],\\\r\n    \"app\": {\\\r\n      \"tailscale.com/cap/aperture\": [\\\r\n        { \"role\": \"user\" },\\\r\n        { \"models\": \"**\",\\\r\n          \"quotas\": [{\"bucket\": \"daily:<user>\"}] },\\\r\n      ],\\\r\n    },\\\r\n  },\\\r\n]\r\n```\r\n\r\nThis creates a separate bucket for each user (for example, `daily:alice@example.com`, `daily:bob@example.com`), each with $10 capacity and a $5/day refill.\r\n\r\n##### [Quota scoped to specific models](https://tailscale.com/docs/aperture/configuration\\#quota-scoped-to-specific-models)\r\n\r\nApply a quota only when the request targets specific models:\r\n\r\n```json\r\n\"quotas\": {\r\n  \"opus:<user>\": {\r\n    \"capacity\": \"$5.00\",\r\n    \"rate\": \"$2.50/day\",\r\n    \"on_exceed\": \"reject\",\r\n  },\r\n},\r\n\"grants\": [\\\r\n  {\\\r\n    \"src\": [\"*\"],\\\r\n    \"app\": {\\\r\n      \"tailscale.com/cap/aperture\": [\\\r\n        // General access — no quota\\\r\n        { \"models\": \"**\" },\\\r\n        // Additional quota for Opus models only\\\r\n        {\\\r\n          \"models\": \"*/claude-opus*\",\\\r\n          \"quotas\": [{\"bucket\": \"opus:<user>\"}],\\\r\n        },\\\r\n      ],\\\r\n    },\\\r\n  },\\\r\n]\r\n```\r\n\r\nThe `opus:<user>` quota applies only when the request targets an Opus model. Other models are unmetered.\r\n\r\n##### [Combine per-user and shared quotas](https://tailscale.com/docs/aperture/configuration\\#combine-per-user-and-shared-quotas)\r\n\r\nCharge each request against both a personal budget and a shared team pool:\r\n\r\n```json\r\n\"quotas\": {\r\n  \"daily:<user>\": {\r\n    \"capacity\": \"$10.00\",\r\n    \"rate\": \"$5.00/day\",\r\n    \"on_exceed\": \"reject\",\r\n  },\r\n  \"team-pool\": {\r\n    \"capacity\": \"$200.00\",\r\n    \"rate\": \"$200.00/day\",\r\n    \"on_exceed\": \"reject\",\r\n  },\r\n},\r\n\"grants\": [\\\r\n  {\\\r\n    \"src\": [\"*\"],\\\r\n    \"app\": {\\\r\n      \"tailscale.com/cap/aperture\": [\\\r\n        { \"role\": \"user\" },\\\r\n        {\\\r\n          \"models\": \"**\",\\\r\n          \"quotas\": [\\\r\n            {\"bucket\": \"daily:<user>\"},\\\r\n            {\"bucket\": \"team-pool\"},\\\r\n          ],\\\r\n        },\\\r\n      ],\\\r\n    },\\\r\n  },\\\r\n]\r\n```\r\n\r\nEvery request deducts from both the user's personal bucket and the shared team pool. Aperture blocks a user who exhausts their $10 daily limit even if the team pool has remaining budget.\r\n\r\n### [`hooks`](https://tailscale.com/docs/aperture/configuration\\#hooks)\r\n\r\nThe `hooks` section specifies webhook endpoints that Aperture calls when conditions match. A unique string key identifies each hook, and grants reference this key. For step-by-step setup, refer to [Build a custom webhook](https://tailscale.com/docs/aperture/how-to/build-custom-webhook). For integration-specific guides, refer to [Integrate Oso with Aperture](https://tailscale.com/docs/aperture/how-to/integrate-oso) or [Integrate Cerbos with Aperture](https://tailscale.com/docs/aperture/how-to/integrate-cerbos).\r\n\r\nThe following example shows the hooks configuration:\r\n\r\n```json\r\n{\r\n  \"hooks\": {\r\n    \"oso\": {\r\n      \"url\": \"https://api.osohq.com/api/agents/v1/model-request\",\r\n      \"apikey\": \"YOUR_OSO_API_KEY\",\r\n      \"timeout\": \"10s\"\r\n    },\r\n    \"my-webhook\": {\r\n      \"url\": \"https://example.com/webhook\",\r\n      \"apikey\": \"YOUR_API_KEY\"\r\n    }\r\n  }\r\n}\r\n```\r\n\r\nEach hook configuration accepts the following fields:\r\n\r\n| Field | Type | Required | Default | Description |\r\n| --- | --- | --- | --- | --- |\r\n| `url` | string | Yes | N/A | HTTP or HTTPS endpoint to POST hook data to. |\r\n| `apikey` | string | No | `\"\"` | API key that Aperture sends to the hook endpoint using the method specified by `authorization`. |\r\n| `authorization` | string | No | `\"bearer\"` | How Aperture sends the API key. Supports the same values as provider authorization: `bearer`, `x-api-key`, `x-goog-api-key`. |\r\n| `timeout` | string | No | `\"5s\"` | Maximum duration to wait for the hook to respond. |\r\n| `disabled` | boolean | No | `false` | Skips this hook when Aperture would otherwise call it. Useful for temporarily disabling a hook without removing its configuration. |\r\n\r\nThe `timeout` field accepts Go duration strings such as `5s`, `30s`, or `1m`. Set to `0` to disable the timeout.\r\n\r\nThe `send_hooks` entries in the `grants` section trigger hooks. A hook specified here does nothing until a grant references it.\r\n\r\n### [Hook grants](https://tailscale.com/docs/aperture/configuration\\#hook-grants)\r\n\r\nTo trigger a hook, add a `send_hooks` entry to a capability in the `grants` section. Hook grants specify which requests trigger the hook and what data to send.\r\n\r\n```json\r\n{\r\n  \"grants\": [\\\r\n    {\\\r\n      \"src\": [\"*\"],\\\r\n      \"app\": {\\\r\n        \"tailscale.com/cap/aperture\": [\\\r\n          {\\\r\n            \"models\": \"**\",\\\r\n            \"send_hooks\": [\\\r\n              {\\\r\n                \"name\": \"oso\",\\\r\n                \"events\": [\"tool_call_entire_request\"],\\\r\n                \"send\": [\"tools\", \"user_message\", \"request_body\", \"response_body\"],\\\r\n              },\\\r\n            ],\\\r\n          },\\\r\n        ],\\\r\n      },\\\r\n    },\\\r\n  ]\r\n}\r\n```\r\n\r\nEach `send_hooks` entry contains the following fields:\r\n\r\n| Field | Type | Description |\r\n| --- | --- | --- |\r\n| `name` | string | Key referencing a hook specified in the top-level `hooks` section. |\r\n| `events` | array | Event types that trigger the hook. |\r\n| `send` | array | List of data types to include in the hook payload. |\r\n\r\n#### [Hook events](https://tailscale.com/docs/aperture/configuration\\#hook-events)\r\n\r\n| Event | Description |\r\n| --- | --- |\r\n| `tool_call_entire_request` | Fires once after the response completes if any message in the response contained tool calls. |\r\n| `entire_request` | Fires for every completed request. |\r\n\r\n#### [Hook send types](https://tailscale.com/docs/aperture/configuration\\#hook-send-types)\r\n\r\nThe `send` array specifies which data to include in the POST payload sent to the hook endpoint:\r\n\r\n| Field | Description |\r\n| --- | --- |\r\n| `tools` | Array of tool calls extracted from the response. |\r\n| `request_body` | The original request body sent to the LLM. |\r\n| `user_message` | The user's message from the request. |\r\n| `response_body` | The reconstructed response body JSON. |\r\n| `raw_responses` | Array of raw SSE messages (for streaming) or single response object. |\r\n| `estimated_cost` | Dollar cost estimate, pricing basis, and token usage breakdown. |\r\n| `grants` | Non-Aperture app capabilities from the user's grants (custom capabilities). |\r\n| `quotas` | Current state of all quota buckets that applied to this request. |\r\n\r\n#### [`estimated_cost`](https://tailscale.com/docs/aperture/configuration\\#estimated_cost)\r\n\r\nIncludes the dollar cost estimate, the pricing basis used, and a token usage breakdown. These fields appear inside the `metadata` object of the hook payload:\r\n\r\n```json\r\n{\r\n  \"models\": \"**\",\r\n  \"send_hooks\": [\\\r\n    {\\\r\n      \"name\": \"audit\",\\\r\n      \"events\": [\"tool_call_entire_request\"],\\\r\n      \"send\": [\"tools\", \"estimated_cost\"],\\\r\n    },\\\r\n  ],\r\n}\r\n```\r\n\r\nThe hook receives the cost data inside `metadata`:\r\n\r\n```json\r\n{\r\n  \"metadata\": {\r\n    \"login_name\": \"user@example.com\",\r\n    \"...\": \"...\",\r\n    \"estimated_cost\": {\r\n      \"dollars\": 0.0342,\r\n      \"cost_basis\": \"anthropic/claude-sonnet-4-5\",\r\n      \"usage\": {\r\n        \"input_tokens\": 1500,\r\n        \"output_tokens\": 800,\r\n        \"cached_tokens\": 200,\r\n        \"reasoning_tokens\": 0\r\n      }\r\n    }\r\n  },\r\n  \"tool_calls\": [...]\r\n}\r\n```\r\n\r\n#### [`grants`](https://tailscale.com/docs/aperture/configuration\\#grants-1)\r\n\r\nIncludes any non-Aperture app capabilities from the user's grants. This lets external systems (policy engines, audit logs) access custom capabilities attached to the user. The `grants` data appears inside the `metadata` object:\r\n\r\n```json\r\n{\r\n  \"src\": [\"alice@example.com\"],\r\n  \"app\": {\r\n    \"tailscale.com/cap/aperture\": [\\\r\n      { \"models\": \"**\" },\\\r\n      {\\\r\n        \"send_hooks\": [\\\r\n          {\\\r\n            \"name\": \"policy-engine\",\\\r\n            \"events\": [\"entire_request\"],\\\r\n            \"send\": [\"estimated_cost\", \"grants\"],\\\r\n          },\\\r\n        ],\\\r\n      },\\\r\n    ],\r\n    \"mycompany.com/cap/policy\": [\\\r\n      {\"tier\": \"enterprise\", \"max_context\": 200000},\\\r\n    ],\r\n  },\r\n}\r\n```\r\n\r\nThe hook receives the custom capabilities inside `metadata`:\r\n\r\n```json\r\n{\r\n  \"metadata\": {\r\n    \"login_name\": \"alice@example.com\",\r\n    \"...\": \"...\",\r\n    \"grants\": {\r\n      \"mycompany.com/cap/policy\": [\\\r\n        {\"tier\": \"enterprise\", \"max_context\": 200000}\\\r\n      ]\r\n    },\r\n    \"estimated_cost\": { \"...\" }\r\n  }\r\n}\r\n```\r\n\r\n#### [`quotas`](https://tailscale.com/docs/aperture/configuration\\#quotas-1)\r\n\r\nIncludes the current state of all quota buckets that applied to the request. The `quotas` data appears inside the `metadata` object:\r\n\r\n```json\r\n\"send\": [\"tools\", \"quotas\"]\r\n```\r\n\r\nThe hook receives the bucket state inside `metadata`:\r\n\r\n```json\r\n{\r\n  \"metadata\": {\r\n    \"login_name\": \"alice@example.com\",\r\n    \"...\": \"...\",\r\n    \"quotas\": {\r\n      \"daily:alice@example.com\": {\r\n        \"current\": 7250000000,\r\n        \"capacity\": 10000000000,\r\n        \"rate\": \"$5.00/day\"\r\n      }\r\n    }\r\n  }\r\n}\r\n```\r\n\r\nValues for `current` and `capacity` are in nanodollars (1 dollar = 1,000,000,000 nanodollars).\r\n\r\nEvery hook call automatically includes a `metadata` object with request context:\r\n\r\n```json\r\n{\r\n  \"metadata\": {\r\n    \"login_name\": \"user@example.com\",\r\n    \"user_agent\": \"curl/8.0\",\r\n    \"url\": \"/v1/chat/completions\",\r\n    \"model\": \"gpt-5\",\r\n    \"provider\": \"openai\",\r\n    \"tailnet_name\": \"example.com\",\r\n    \"stable_node_id\": \"n12345\",\r\n    \"request_id\": \"abc123\",\r\n    \"session_id\": \"oacc_1a2b3c4d5e6f7890\"\r\n  }\r\n}\r\n```\r\n\r\n#### [Hook grant example](https://tailscale.com/docs/aperture/configuration\\#hook-grant-example)\r\n\r\nThe following example sends tool call data and cost estimates to an audit service for all requests from a specific user:\r\n\r\n```json\r\n{\r\n  \"grants\": [\\\r\n    {\\\r\n      \"src\": [\"developer@company.com\"],\\\r\n      \"app\": {\\\r\n        \"tailscale.com/cap/aperture\": [\\\r\n          {\\\r\n            \"models\": \"anthropic/**\",\\\r\n            \"send_hooks\": [\\\r\n              {\\\r\n                \"name\": \"my-webhook\",\\\r\n                \"events\": [\"tool_call_entire_request\"],\\\r\n                \"send\": [\"tools\", \"user_message\", \"estimated_cost\"],\\\r\n              },\\\r\n            ],\\\r\n          },\\\r\n          {\\\r\n            \"models\": \"openai/**\",\\\r\n            \"send_hooks\": [\\\r\n              {\\\r\n                \"name\": \"my-webhook\",\\\r\n                \"events\": [\"tool_call_entire_request\"],\\\r\n                \"send\": [\"tools\", \"user_message\", \"estimated_cost\"],\\\r\n              },\\\r\n            ],\\\r\n          },\\\r\n        ],\\\r\n      },\\\r\n    },\\\r\n  ]\r\n}\r\n```\r\n\r\n### [`exporters`](https://tailscale.com/docs/aperture/configuration\\#exporters)\r\n\r\nThe `exporters` section configures periodic export of LLM session logs to external storage. Currently, Aperture supports exporting to S3-compatible storage (AWS S3, Google Cloud Storage, MinIO, Backblaze B2, and others). For step-by-step setup, refer to [Export usage data to S3](https://tailscale.com/docs/aperture/how-to/export-usage-data-to-s3).\r\n\r\nThe following example shows the exporters configuration:\r\n\r\n```json\r\n{\r\n  \"exporters\": {\r\n    \"s3\": {\r\n      \"endpoint\": \"https://your-s3-compatible-endpoint.url\",\r\n      \"bucket_name\": \"aperture-exports\",\r\n      \"region\": \"us-east-1\",\r\n      \"prefix\": \"prod\",\r\n      \"access_key_id\": \"YOUR_AWS_KEY\",\r\n      \"access_secret\": \"YOUR_AWS_SECRET\",\r\n      \"every\": 3600,\r\n      \"limit\": 1000\r\n    }\r\n  }\r\n}\r\n```\r\n\r\nSetting `bucket_name` to a non-empty value enables the S3 exporter. Each S3 exporter configuration accepts the following fields:\r\n\r\n| Field | Type | Required | Default | Description |\r\n| --- | --- | --- | --- | --- |\r\n| `endpoint` | string | No | `\"\"` | HTTP endpoint for an S3-compatible API. Required for non-AWS services (GCS, MinIO, Backblaze B2). Omit for Amazon S3, where Aperture infers the endpoint from `region`. |\r\n| `bucket_name` | string | Conditional | `null` | Name of the S3 bucket to upload exports to. Setting this field to a non-empty value enables the S3 exporter. |\r\n| `region` | string | No | `\"us-east-1\"` | AWS region for the bucket. Required even for non-AWS endpoints because the AWS SDK validates this field. |\r\n| `prefix` | string | No | `\"\"` | Path prefix for new S3 objects. Must not end with `/`. |\r\n| `access_key_id` | string | Conditional | `null` | AWS access key ID used to authenticate. Required and must be non-empty when `bucket_name` is set and static credentials are used. |\r\n| `access_secret` | string | Conditional | `null` | Secret key used with `access_key_id` to authenticate. Required and must be non-empty when `access_key_id` is set. |\r\n| `every` | int | No | `3600` | Number of seconds to wait after the last export before starting another. Default is one hour. |\r\n| `limit` | int | No | `1000` | Maximum number of records per export. Aperture caps this value at `2500` and silently reduces higher values. |\r\n\r\n### [MCP](https://tailscale.com/docs/aperture/configuration\\#mcp)\r\n\r\nAperture's MCP server support is experimental. The MCP configuration syntax may change.\r\n\r\nThe `mcp` section configures MCP (Model Context Protocol) server proxying. Aperture connects to remote MCP servers, aggregates their tools, resources, and resource templates, and exposes them through a single `/v1/mcp` endpoint. Refer to [MCP server proxying](https://tailscale.com/docs/aperture/how-to/mcp-server) for setup instructions and troubleshooting.\r\n\r\n```json\r\n{\r\n  \"mcp\": {\r\n    \"accept_registrations\": true,\r\n    \"servers\": {\r\n      \"local\": {\r\n        \"url\": \"http://localhost:8185/v1/mcp\"\r\n      },\r\n      \"remote\": {\r\n        \"url\": \"http://mcp-server.example.ts.net:8080/v1/mcp\"\r\n      }\r\n    }\r\n  }\r\n}\r\n```\r\n\r\n#### [`mcp` fields](https://tailscale.com/docs/aperture/configuration\\#mcp-fields)\r\n\r\n| Field | Type | Default | Description |\r\n| --- | --- | --- | --- |\r\n| `accept_registrations` | boolean | `false` | Allow backends to register dynamically through `POST /v1/mcp/register`. Backends POST `{\"url\": \"http://...\"}` and keep the connection open. Tools are unregistered when the connection closes. |\r\n| `servers` | map | `{}` | Map of server ID to server configuration. The map key is the server ID, which becomes the name prefix for tools (`serverID_toolname`), resources (`serverID-uri`), and resource templates (`serverID-uriTemplate`) from that backend. |\r\n\r\n#### [`servers` fields](https://tailscale.com/docs/aperture/configuration\\#servers-fields)\r\n\r\nEach entry in the `servers` map accepts the following fields:\r\n\r\n| Field | Type | Required | Description |\r\n| --- | --- | --- | --- |\r\n| `url` | string | Yes | The MCP server endpoint URL (for example, `http://localhost:8185/v1/mcp`). |\r\n\r\n## [Quota enforcement](https://tailscale.com/docs/aperture/configuration\\#quota-enforcement)\r\n\r\nAperture enforces quotas at request time by checking all referenced buckets before forwarding a request to the provider.\r\n\r\n### [What happens when a request exceeds a quota](https://tailscale.com/docs/aperture/configuration\\#what-happens-when-a-request-exceeds-a-quota)\r\n\r\nWhen a request would exceed any of its quota buckets, Aperture:\r\n\r\n1. Rejects the request with HTTP status **429 (Too Many Requests)**.\r\n2. Sets a `Retry-After` header with the estimated seconds until enough budget refills.\r\n3. Formats the error to match the provider's native error format.\r\n4. Logs a warning with the bucket detail, login name, and model.\r\n\r\nThe following table describes the provider-specific error formats:\r\n\r\n| Provider | Error format |\r\n| --- | --- |\r\n| Anthropic | `{\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\",\"message\":\"...\"}}` |\r\n| OpenAI | `{\"error\":{\"message\":\"...\",\"type\":\"insufficient_quota\",\"code\":\"insufficient_quota\"}}` |\r\n| Bedrock | `{\"message\":\"...\"}` with `x-amzn-ErrorType: ThrottlingException` |\r\n| Google/Vertex | `{\"error\":{\"code\":429,\"message\":\"...\",\"status\":\"RESOURCE_EXHAUSTED\"}}` |\r\n\r\n### [Where Aperture logs enforcement](https://tailscale.com/docs/aperture/configuration\\#where-aperture-logs-enforcement)\r\n\r\nAperture logs quota exceeded events at the **Warn** level in the server log with the following fields:\r\n\r\n- `detail`: Which buckets Aperture exhausted.\r\n- `login_name`: The user that Aperture blocked.\r\n- `model`: The `provider/model` that the user requested.\r\n\r\n## [Validation](https://tailscale.com/docs/aperture/configuration\\#validation)\r\n\r\nAperture validates configuration at load time and reports problems. Some issues are fatal errors that prevent the configuration from loading, while others are warnings that let the configuration load but should be addressed. The following table describes the validations.\r\n\r\nAperture handles validation differently depending on how the configuration is loaded:\r\n\r\n- **When Aperture loads configuration at startup or reload**: Aperture logs warnings but loads the configuration successfully. This lets Aperture start even with minor issues.\r\n- **When saving through the API or the Settings page of the Aperture dashboard**: Aperture treats warnings as errors and rejects the save. This ensures that configurations saved through the UI are warning-free.\r\n- **When using the validate endpoint (`POST /aperture/config:validate`)**: Aperture surfaces warnings as validation errors (the response sets `Valid: false`), matching the save behavior.\r\n\r\nThe admin lockout check only applies when saving — it prevents you from accidentally removing your own admin access.\r\n\r\n| Condition | Message | Severity |\r\n| --- | --- | --- |\r\n| No providers or MCP servers | `no providers or mcp servers defined; users will not be able to access any models` | Warning |\r\n| Provider missing `baseurl` | `provider {id} has no baseurl configured` | Warning |\r\n| Invalid `authorization` type | `provider {id} has invalid authorization type: {type}` | Warning |\r\n| Unresolved environment variable | `unsubstituted macros: [var_name]` | Error |\r\n| Invalid JSON or HUJSON syntax | Parse error details | Error |\r\n| Invalid quota definition | `quota {name}: {details}` | Error |\r\n| **Structural / syntax** |  |  |\r\n| Duplicate keys in config | `duplicate config key \"hostname\"` | Warning |\r\n| Unknown config keys | `unknown config key \"basurl\"` | Warning |\r\n| Type mismatch in field value | Field name and `json.UnmarshalTypeError` details | Warning |\r\n| **Provider** |  |  |\r\n| Invalid `add_headers` format | `provider {id}: add_headers entry \"Bad-Entry\" must be in \"Header-Name: value\" format` | Warning |\r\n| **Quota** |  |  |\r\n| Invalid quota name template | `quota \"{name}\": quota name \"{name}\" has unsupported template \"{template}\" after colon` | Warning |\r\n| **Grant** |  |  |\r\n| Unknown fields in grant | Strict JSON parsing error for the unrecognized field | Warning |\r\n| Grant `models` references undefined provider | `models pattern \"{pattern}\" references provider \"{name}\" which does not match any declared provider` | Warning |\r\n| Grant `mcp_tools`, `mcp_resources`, or `mcp_templates` references undefined MCP server | `mcp_tools pattern \"{pattern}\" references server \"{name}\" which does not match any declared MCP server` | Warning |\r\n| Grant `send_hooks` references undefined hook | `send_hooks references undefined hook \"{name}\"` | Warning |\r\n| Grant `quotas` references undefined quota | `quotas references undefined quota \"{name}\"` | Warning |\r\n| Invalid quota bucket ref template in grant | `grants[{n}] grant {n} quotas[{n}]: bucket ref \"{ref}\" has unsupported template...` | Warning |\r\n| Grant-level `add_headers` invalid format | `add_headers entry \"{entry}\" must be in \"Header-Name: value\" format` | Warning |\r\n| **Hook** |  |  |\r\n| Hook missing URL | `hook {name} has no url configured` | Warning |\r\n| Hook invalid URL scheme | `hook {name} has invalid URL scheme (must be http:// or https://): {url}` | Warning |\r\n| Hook invalid authorization type | `hook {name} has invalid authorization type: {type}` | Warning |\r\n| **Exporter** |  |  |\r\n| S3 prefix ends with `/` | `exporters.s3.prefix must not end with a slash` | Warning |\r\n| **Structural warnings** |  |  |\r\n| No grants assign admin role | `no grant in grants or temp_grants assigns role:admin; nobody will be able to manage this instance unless admin is granted via tsnet ACL policy` | Warning |\r\n| Providers configured but no grants defined | `providers are configured but no grants or temp_grants defined; all access will be denied` | Warning |\r\n| **Save-only** |  |  |\r\n| Empty configuration | `config must not be empty` | Error |\r\n| Admin lockout prevention | Rejects saves that would remove the saving user's admin access | Error |\r\n\r\nAperture silently caps S3 exporter `limit_records` values above 2500 to 2500.\r\n\r\nFor common validation issues and how to resolve them, refer to [troubleshooting](https://tailscale.com/docs/aperture/troubleshooting).\r\n\r\n## [Complete example](https://tailscale.com/docs/aperture/configuration\\#complete-example)\r\n\r\nThe following example shows a complete configuration with all sections:\r\n\r\n```json\r\n{\r\n  // Access control: who can use which models\r\n  \"grants\": [\\\r\n    // All users: access all models with per-user and org-wide quotas\\\r\n    {\\\r\n      \"src\": [\"*\"],\\\r\n      \"app\": {\\\r\n        \"tailscale.com/cap/aperture\": [\\\r\n          { \"role\": \"user\" },\\\r\n          {\\\r\n            \"models\": \"**\",\\\r\n            \"quotas\": [\\\r\n              {\"bucket\": \"daily:<user>\"},\\\r\n              {\"bucket\": \"org-monthly\"},\\\r\n            ],\\\r\n          },\\\r\n        ],\\\r\n      },\\\r\n    },\\\r\n    // Admin access for specific user with audit hook\\\r\n    {\\\r\n      \"src\": [\"admin@company.com\"],\\\r\n      \"app\": {\\\r\n        \"tailscale.com/cap/aperture\": [\\\r\n          { \"role\": \"admin\" },\\\r\n          {\\\r\n            \"models\": \"**\",\\\r\n            \"send_hooks\": [\\\r\n              {\\\r\n                \"name\": \"oso\",\\\r\n                \"events\": [\"tool_call_entire_request\"],\\\r\n                \"send\": [\"tools\", \"estimated_cost\"],\\\r\n              },\\\r\n            ],\\\r\n          },\\\r\n        ],\\\r\n      },\\\r\n    },\\\r\n  ],\r\n\r\n  // Dollar-based spending limits\r\n  \"quotas\": {\r\n    \"daily:<user>\": {\r\n      \"capacity\": \"$10.00\",\r\n      \"rate\": \"$5.00/day\",\r\n      \"on_exceed\": \"reject\",\r\n    },\r\n    \"org-monthly\": {\r\n      \"capacity\": \"$2000.00\",\r\n      \"rate\": \"$2000.00/month\",\r\n      \"on_exceed\": \"reject\",\r\n    },\r\n  },\r\n\r\n  // LLM session log export configuration\r\n  \"exporters\": {\r\n    \"s3\": {\r\n      // Required for S3-compatible services (GCS, MinIO, Backblaze B2, and others)\r\n      \"endpoint\": \"https://your-s3-compatible-endpoint.url\",\r\n      \"bucket_name\": \"aperture-exports\",\r\n      \"region\": \"us-west-2\",\r\n      \"prefix\": \"prod\",\r\n      \"access_key_id\": \"YOUR_AWS_KEY\",\r\n      \"access_secret\": \"YOUR_AWS_SECRET\",\r\n      \"every\": 3600,\r\n      \"limit\": 1000\r\n    }\r\n  },\r\n\r\n  // LLM providers\r\n  \"providers\": {\r\n    \"openai\": {\r\n      \"baseurl\": \"https://api.openai.com/\",\r\n      \"apikey\": \"YOUR_OPENAI_KEY\",\r\n      \"models\": [\"gpt-5\", \"gpt-5-mini\", \"gpt-4.1\"],\r\n      \"name\": \"OpenAI\",\r\n      \"description\": \"OpenAI models for coding and chat\",\r\n      \"compatibility\": {\r\n        \"openai_chat\": true,\r\n        \"openai_responses\": true\r\n      }\r\n    },\r\n    \"anthropic\": {\r\n      \"baseurl\": \"https://api.anthropic.com\",\r\n      \"apikey\": \"YOUR_PROXY_ANTHROPIC_KEY\",\r\n      \"authorization\": \"x-api-key\",\r\n      \"models\": [\"claude-sonnet-4-5\", \"claude-haiku-4-5\", \"claude-opus-4-5\"],\r\n      \"name\": \"Anthropic\",\r\n      \"compatibility\": {\r\n        \"openai_chat\": false,\r\n        \"anthropic_messages\": true\r\n      }\r\n    },\r\n    \"gemini\": {\r\n      \"baseurl\": \"https://generativelanguage.googleapis.com\",\r\n      \"apikey\": \"YOUR_PROXY_GEMINI_KEY\",\r\n      \"authorization\": \"x-goog-api-key\",\r\n      \"models\": [\"gemini-2.5-flash\", \"gemini-2.5-pro\"],\r\n      \"name\": \"Google Gemini\",\r\n      \"compatibility\": {\r\n        \"openai_chat\": false,\r\n        \"gemini_generate_content\": true\r\n      }\r\n    },\r\n    \"private\": {\r\n      \"baseurl\": \"YOUR_PRIVATE_LLM_URL\",\r\n      \"models\": [\"qwen3-coder-30b\"]\r\n    }\r\n  },\r\n\r\n  // Hooks for external integrations\r\n  \"hooks\": {\r\n    \"oso\": {\r\n      \"url\": \"https://api.osohq.com/api/agents/v1/model-request\",\r\n      \"apikey\": \"YOUR_OSO_API_KEY\",\r\n    },\r\n  },\r\n\r\n  // MCP server proxying\r\n  \"mcp\": {\r\n    \"accept_registrations\": true,\r\n    \"servers\": {\r\n      \"tools\": {\r\n        \"url\": \"http://mcp-tools.example.ts.net:8080/v1/mcp\",\r\n      },\r\n    },\r\n  },\r\n}\r\n```\r\n\r\n![Project Logo](https://cdn.brandfetch.io/tailscale.com/fallback/lettermark/theme/dark/h/256/w/256/icon?c=1bfwsmEH20zzEfSNTed)\r\n\r\nAsk AI\r\n\r\nreCAPTCHA\r\n\r\nRecaptcha requires verification.\r\n\r\nprotected by **reCAPTCHA**\r\n","html":"<h1>Aperture by Tailscale configuration</h1>\n<p>Last validated: Apr 9, 2026</p>\n<p>Aperture by Tailscaleis currently <a href=\"https://tailscale.com/docs/reference/tailscale-release-stages#alpha\">in alpha</a>.</p>\n<p>During the alpha testing period, Aperture by Tailscale is available at no additional cost across <a href=\"https://tailscale.com/pricing\">all Tailscale plans</a>. Request access at <a href=\"https://aperture.tailscale.com/\"><code>aperture.tailscale.com</code></a>. Aperture by Tailscale comes with six free users. Contact Tailscale for pricing if you need more than six users.</p>\n<p><a href=\"https://tailscale.com/docs/aperture\">Aperture by Tailscale</a> uses a JSON configuration to specify LLM providers, access control policies, and optional integrations. The Aperture configuration controls which models are available, how requests authenticate with upstream providers, and who can access what. Admins can edit the configuration from the <strong>Settings</strong> page of the <a href=\"https://tailscale.com/docs/aperture/reference/dashboard\">Aperture dashboard</a> using the <strong>Visual editor</strong> (default) or the <strong>JSON editor</strong>.</p>\n<p>This page is part of the <a href=\"https://tailscale.com/docs/aperture/reference\">Aperture reference</a> documentation.</p>\n<h2><a href=\"https://tailscale.com/docs/aperture/configuration#minimal-configuration\">Minimal configuration</a></h2>\n<p>A minimal configuration requires at least one provider with a base URL and at least one model. The following example shows a minimal configuration:</p>\n<pre><code class=\"language-json\">{\r\n  \"providers\": {\r\n    \"anthropic\": {\r\n      \"baseurl\": \"https://api.anthropic.com\",\r\n      \"apikey\": \"YOUR_ANTHROPIC_API_KEY\",\r\n      \"models\": [\\\r\n        \"claude-sonnet-4-5\",\\\r\n        \"claude-opus-4-5\",\\\r\n      ],\r\n      \"authorization\": \"x-api-key\",\r\n      \"compatibility\": {\r\n        \"anthropic_messages\": true,\r\n      }\r\n    }\r\n  }\r\n}\n</code></pre>\n<p>If you omit <code>apikey</code>, Aperture logs a warning at startup but continues to run. Most providers require an API key for authentication, so add one unless your provider handles authentication differently.</p>\n<p>The <code>apikey</code> field requires an API key from the provider's developer platform. Consumer and business subscription plans (such as <a href=\"https://www.anthropic.com/pricing\">Claude Pro or Claude Max</a>, <a href=\"https://openai.com/chatgpt/pricing\">ChatGPT Plus, Pro, or Team</a>, or <a href=\"https://gemini.google.com/\">Gemini Advanced</a>) do not provide API keys and are not compatible with Aperture.</p>\n<h2><a href=\"https://tailscale.com/docs/aperture/configuration#default-configuration\">Default configuration</a></h2>\n<p>New Aperture instances use a default configuration that includes OpenAI and Anthropic providers with common models. The default grants all users access to all models. The following shows the default configuration:</p>\n<pre><code class=\"language-json\">{\r\n    // The grants section uses the Tailscale ACL grant structure.\r\n    \"grants\": [\\\r\n        // Grant admin access (permission to see the settings and all other\\\r\n        // users in the dashboard).\\\r\n        {\\\r\n            \"src\": [\\\r\n                // Explicitly identify certain users by their Tailscale login.\\\r\n                \"example-user@example.com\",\\\r\n\\\r\n                // Grant admin access to everyone by default.\\\r\n                // Remove this after you've configured explicit admin\\\r\n                // access for yourself.\\\r\n                // BE CAREFUL! If you remove this without granting explicit\\\r\n                // admin access to yourself, you'll lose your ability\\\r\n                // to edit this file.\\\r\n                \"*\",\\\r\n            ],\\\r\n            \"app\": {\\\r\n                \"tailscale.com/cap/aperture\": [\\\r\n                    { \"role\": \"admin\" },\\\r\n                ],\\\r\n            },\\\r\n        },\\\r\n\\\r\n        // Every user who can access Aperture gets at least user-level access.\\\r\n        // Remove this and Aperture denies access entirely by default.\\\r\n        // Admin access in a separate grant takes precedence over this section.\\\r\n        {\\\r\n            \"src\": [\"*\"],\\\r\n            \"app\": {\\\r\n                \"tailscale.com/cap/aperture\": [\\\r\n                    { \"role\": \"user\" },\\\r\n                ],\\\r\n            },\\\r\n        },\\\r\n\\\r\n        // Default: allow all users to access all models from all providers.\\\r\n        // Without this grant, users can't access any models (deny by default).\\\r\n        {\\\r\n            \"src\": [\"*\"],\\\r\n            \"app\": {\\\r\n                \"tailscale.com/cap/aperture\": [\\\r\n                    { \"models\": \"**\" },\\\r\n                ],\\\r\n            },\\\r\n        },\\\r\n\\\r\n        // This example hook sends traffic to Oso if it matches certain\\\r\n        // parameters. Configure Oso in the \"hooks\" section for this to work.\\\r\n        {\\\r\n            \"src\": [\\\r\n                // No users by default. Try \"*\" to capture everyone's traffic.\\\r\n            ],\\\r\n            \"app\": {\\\r\n                \"tailscale.com/cap/aperture\": [\\\r\n                    {\\\r\n                        \"send_hooks\": [\\\r\n                            {\\\r\n                                \"name\": \"oso\",\\\r\n                                // Capturing only tool calls\\\r\n                                \"events\": [\"tool_call_entire_request\"],\\\r\n                                \"send\": [\"user_message\", \"tools\", \"request_body\", \"response_body\"],\\\r\n                            },\\\r\n                        ],\\\r\n                    },\\\r\n                ],\\\r\n            },\\\r\n        },\\\r\n    ],\r\n\r\n    // Configure your LLM backends here.\r\n    // Fill your API keys in below to share these providers with your team.\r\n    // There's no limit to the number of providers you can configure.\r\n    \"providers\": {\r\n        \"openai\": {\r\n            \"baseurl\": \"https://api.openai.com\",\r\n            \"name\": \"OpenAI\",\r\n            \"apikey\": \"YOUR_OPENAI_API_KEY\",\r\n            \"models\": [\\\r\n                \"gpt-5\",\\\r\n                \"gpt-5-mini\",\\\r\n                \"gpt-5-nano\",\\\r\n                \"gpt-4.1\",\\\r\n                \"gpt-4.1-nano\",\\\r\n                \"gpt-5.1-codex\",\\\r\n                \"gpt-5.1-codex-max\",\\\r\n            ],\r\n            \"compatibility\": {\r\n                \"openai_chat\": true,\r\n                \"openai_responses\": true,\r\n                \"anthropic_messages\": false,\r\n            },\r\n        },\r\n        \"anthropic\": {\r\n            \"baseurl\": \"https://api.anthropic.com\",\r\n            \"name\": \"Anthropic\",\r\n            \"apikey\": \"YOUR_ANTHROPIC_API_KEY\",\r\n            \"models\": [\\\r\n                \"claude-sonnet-4-5\",\\\r\n                \"claude-sonnet-4-5-20250929\",\\\r\n                \"claude-haiku-4-5\",\\\r\n                \"claude-haiku-4-5-20251001\",\\\r\n                \"claude-opus-4-5\",\\\r\n                \"claude-opus-4-5-20251101\",\\\r\n            ],\r\n            \"compatibility\": {\r\n                \"openai_chat\": false,\r\n                \"openai_responses\": false,\r\n                \"anthropic_messages\": true,\r\n            },\r\n        },\r\n    },\r\n\r\n    // Hooks are configured API endpoints that Aperture calls under certain\r\n    // conditions. The conditions themselves are configured in the\r\n    // \"grants\" section.\r\n    \"hooks\": {\r\n        \"oso\": {\r\n            \"url\":    \"https://api.osohq.com/api/agents/v1/model-request\",\r\n            \"apikey\": \"YOUR_OSO_API_KEY\",\r\n        },\r\n    },\r\n}\n</code></pre>\n<h2><a href=\"https://tailscale.com/docs/aperture/configuration#configuration-reference\">Configuration reference</a></h2>\n<p>The Aperture configuration contains several top-level sections that control different aspects of Aperture's behavior. The following table describes the available top-level sections:</p>\n<p>| Section | Required | Description |\r\n| --- | --- | --- |\r\n| <code>providers</code> | Yes | Map of LLM provider configurations. |\r\n| <code>grants</code> | No | Access control policies for users, models, and quotas. Uses the Tailscale grant structure. |\r\n| <code>quotas</code> | No | Dollar-based spending limits using token buckets. |\r\n| <code>hooks</code> | No | Webhook endpoint configurations. |\r\n| <code>exporters</code> | No | LLM session log export configuration. Currently supports S3-compatible storage. |\r\n| <code>auto_cost_basis</code> | No | Boolean (default <code>true</code>). When <code>true</code>, Aperture infers <code>cost_basis</code> from a provider's compatibility flags when no explicit <code>cost_basis</code> is set. Set to <code>false</code> to disable auto-inference, so only providers with an explicit <code>cost_basis</code> produce cost estimates. |</p>\n<h3><a href=\"https://tailscale.com/docs/aperture/configuration#providers\"><code>providers</code></a></h3>\n<p>The <code>providers</code> section specifies the LLM providers to which Aperture routes requests. A unique string key identifies each provider. The following example shows the basic structure:</p>\n<pre><code class=\"language-json\">{\r\n  \"providers\": {\r\n    \"openai\": { ... },\r\n    \"anthropic\": { ... },\r\n    \"private\": { ... }\r\n  }\r\n}\n</code></pre>\n<p>Each provider configuration accepts the following fields:</p>\n<p>| Field | Type | Required | Default | Description |\r\n| --- | --- | --- | --- | --- |\r\n| <code>baseurl</code> | string | Yes | N/A | Base URL for the provider's API. |\r\n| <code>models</code> | array | Yes | N/A | List of model IDs available from this provider. |\r\n| <code>apikey</code> | string | No | <code>\"\"</code> | API key for authentication. |\r\n| <code>authorization</code> | string | No | <code>\"bearer\"</code> | Authorization header type. |\r\n| <code>name</code> | string | No | <code>\"\"</code> | Display name for the UI. |\r\n| <code>description</code> | string | No | <code>\"\"</code> | Description shown in the UI. |\r\n| <code>compatibility</code> | object | No | <code>openai_chat</code> enabled, all others disabled | API compatibility flags. Refer to the <a href=\"https://tailscale.com/docs/aperture/provider-compatibility\">provider compatibility reference</a> for details. |\r\n| <code>cost_basis</code> | string | No | Auto-inferred | Override the pricing service used for cost estimation. Refer to the <a href=\"https://tailscale.com/docs/aperture/provider-compatibility\">provider compatibility reference</a> for valid values. |\r\n| <code>preference</code> | int | No | <code>0</code> | Routing priority when a model is available from multiple providers. Higher value wins. |\r\n| <code>disabled</code> | bool | No | <code>false</code> | Deactivates the provider without removing its configuration. Disabled providers are excluded from routing and <code>/v1/models</code>. |\r\n| <code>add_headers</code> | array | No | <code>[]</code> | Custom headers added to every upstream request for this provider. Each entry uses <code>\"Header-Name: value\"</code> format. |\r\n| <code>model_cost_map</code> | array | No | <code>[]</code> | Map unknown model names to known models for pricing. Refer to <a href=\"https://tailscale.com/docs/aperture/provider-compatibility#model-cost-map\">model cost map</a>. |</p>\n<p>The <code>authorization</code> field is not required for all providers. For example, Vertex AI uses a service account key file instead of an API key (prefixed with <code>keyfile::</code>). Refer to <a href=\"https://tailscale.com/docs/aperture/how-to/use-vertex-ai\">set up a Vertex AI provider</a> for step-by-step configuration instructions.</p>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#authorization-types\">Authorization types</a></h4>\n<p>Different providers require different authorization header formats. The <code>authorization</code> field supports <code>bearer</code> (default, used by OpenAI and most providers), <code>x-api-key</code> (Anthropic), and <code>x-goog-api-key</code> (Google Gemini). For the full authorization types table and provider-specific details, refer to the <a href=\"https://tailscale.com/docs/aperture/provider-compatibility\">provider compatibility reference</a>.</p>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#provider-compatibility\">Provider compatibility</a></h4>\n<p>The <code>compatibility</code> object specifies which API formats the provider supports. This determines which endpoints Aperture exposes for the provider's models. Flags include <code>openai_chat</code> (default enabled), <code>openai_responses</code>, <code>anthropic_messages</code>, <code>gemini_generate_content</code>, <code>bedrock_model_invoke</code>, and others. For the complete compatibility flags table, provider matrix, and configuration examples for each provider, refer to the <a href=\"https://tailscale.com/docs/aperture/provider-compatibility\">provider compatibility reference</a>.</p>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#provider-examples\">Provider examples</a></h4>\n<p>For complete configuration examples for each supported provider (OpenAI, Anthropic, Google Gemini, Vertex AI, Amazon Bedrock, OpenRouter, and self-hosted LLMs), refer to the <a href=\"https://tailscale.com/docs/aperture/provider-compatibility\">provider compatibility reference</a>.</p>\n<h3><a href=\"https://tailscale.com/docs/aperture/configuration#pricing-and-cost-estimation\">Pricing and cost estimation</a></h3>\n<p>Aperture estimates the dollar cost of every LLM request. Cost estimates power quotas, hook metadata, and the per-model pricing shown in the Aperture dashboard. Aperture auto-infers pricing for known providers based on compatibility flags, but you can override this with the <code>cost_basis</code> and <code>model_cost_map</code> provider fields. For the full list of <code>cost_basis</code> values, model cost mapping syntax, and per-provider pricing details, refer to the <a href=\"https://tailscale.com/docs/aperture/provider-compatibility\">provider compatibility reference</a>.</p>\n<p>Aperture surfaces cost data in several places:</p>\n<ul>\n<li><strong>Models</strong> page of the Aperture dashboard: Each model shows per-million-token pricing (input/output) with a tooltip that includes cache, reasoning, image, and web search rates.</li>\n<li><strong>CSV export</strong>: The <strong>Adoption</strong> page's <strong>Download CSV</strong> button exports usage data including token counts per model, user, and date.</li>\n<li><strong>Hooks</strong>: Include <code>\"estimated_cost\"</code> in a hook's <code>send</code> array to receive dollar cost, cost basis, and token usage with every hook call. Refer to <a href=\"https://tailscale.com/docs/aperture/configuration#hook-send-types\">hook send types</a> for details.</li>\n</ul>\n<h3><a href=\"https://tailscale.com/docs/aperture/configuration#grants\"><code>grants</code></a></h3>\n<p>The <code>grants</code> section specifies access control policies that determine which users can access which models, what hooks fire, and which quotas apply. Grants use the <a href=\"https://tailscale.com/docs/features/access-control/grants/grants-app-capabilities\">Tailscale grant structure</a> with capabilities scoped under <code>\"tailscale.com/cap/aperture\"</code>. Aperture is deny-by-default: without a matching grant, a user cannot access any models.</p>\n<p>For step-by-step instructions on configuring access, refer to <a href=\"https://tailscale.com/docs/aperture/how-to/grant-model-access\">Control model access</a> and <a href=\"https://tailscale.com/docs/aperture/how-to/set-up-admin-access\">Set up admin access</a>.</p>\n<p>The <code>grants</code> section replaces the deprecated <code>temp_grants</code> syntax with a new structure. The <code>temp_grants</code> syntax still works but is not recommended for new configurations.</p>\n<p>You can specify grants in two places:</p>\n<ol>\n<li><strong>Aperture configuration</strong>: In the <code>\"grants\"</code> array (described below).</li>\n<li><strong>Tailnet policy file</strong>: As app capabilities under <code>\"tailscale.com/cap/aperture\"</code>, delivered to Aperture automatically through the Tailscale coordination server.</li>\n</ol>\n<p>Aperture merges grants from both sources additively. Roles escalate (user to admin) but never downgrade.</p>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#basic-structure\">Basic structure</a></h4>\n<p>A grant specifies a source (<code>src</code>) and a set of app capabilities:</p>\n<pre><code class=\"language-json\">\"grants\": [\\\r\n  {\\\r\n    \"src\": [\"*\"],               // who this grant applies to\\\r\n    \"app\": {\\\r\n      \"tailscale.com/cap/aperture\": [\\\r\n        // array of individual capabilities\\\r\n        { \"models\": \"**\" },     // allow access to all models\\\r\n      ],\\\r\n    },\\\r\n  },\\\r\n]\n</code></pre>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#source-match-src\">Source match (<code>src</code>)</a></h4>\n<p>The <code>src</code> field determines which users a grant applies to:</p>\n<ul>\n<li><code>\"*\"</code>: Matches everyone.</li>\n<li><code>\"alice@example.com\"</code>: Matches a specific Tailscale login name.</li>\n<li><code>\"(loopback)\"</code>: Matches local/loopback requests (useful for development).</li>\n</ul>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#model-access\">Model access</a></h4>\n<p>Each <code>models</code> field accepts a single glob pattern using fully-qualified <code>provider/model</code> format. To match multiple providers, use separate grant entries.</p>\n<p>| Pattern | Matches |\r\n| --- | --- |\r\n| <code>\"**\"</code> | All models from all providers |\r\n| <code>\"anthropic/**\"</code> | All Anthropic models |\r\n| <code>\"openai/gpt-5\"</code> | Exactly <code>openai/gpt-5</code> |\r\n| <code>\"*/claude-sonnet*\"</code> | Any <code>claude-sonnet*</code> model from any single provider |\r\n| <code>\"aperture-*/**\"</code> | Any model from a provider whose name starts with <code>aperture-</code> |</p>\n<p><code>*</code> matches a single path segment. <code>**</code> matches zero or more segments.</p>\n<p>A grant with no <code>models</code> field is \"floating,\" meaning it applies globally (useful for hooks and quotas that apply regardless of model).</p>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#role-assignment\">Role assignment</a></h4>\n<p>Roles determine a user's permission level:</p>\n<pre><code class=\"language-json\">{ \"role\": \"admin\" }   // full admin access\r\n{ \"role\": \"user\" }    // standard user access\n</code></pre>\n<p>Without a role grant, the user cannot access Aperture. If multiple grants match a given user, the highest-permissioned role (admin) wins.</p>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#mcp-access\">MCP access</a></h4>\n<p>Aperture's MCP server support is experimental. The MCP grants syntax may change.</p>\n<p>Grant access to registered MCP items in the same way as models:</p>\n<pre><code class=\"language-json\">{\r\n  \"mcp_tools\": \"local/*\",       // tools from the \"local\" MCP server\r\n  \"mcp_resources\": \"**\",        // all resources from all servers\r\n  \"mcp_templates\": \"remote/*\",  // templates from \"remote\" server\r\n}\n</code></pre>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#additional-capability-fields\">Additional capability fields</a></h4>\n<p>Each capability object in the <code>tailscale.com/cap/aperture</code> array can also include these fields:</p>\n<p>| Field | Type | Description |\r\n| --- | --- | --- |\r\n| <code>add_headers</code> | array | Custom headers prepended to upstream requests when this grant matches. Each entry uses <code>\"Header-Name: value\"</code> format. |\r\n| <code>enable_chat_ui</code> | bool | Grants access to the chat UI feature. |\r\n| <code>read_metrics</code> | bool | Grants access to the <code>/metrics</code> Prometheus endpoint. |</p>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#custom-app-capabilities\">Custom app capabilities</a></h4>\n<p>Grants can include capability keys beyond <code>tailscale.com/cap/aperture</code>. Aperture passes these through to hooks when you include the <code>\"grants\"</code> send type:</p>\n<pre><code class=\"language-json\">{\r\n  \"src\": [\"admin@example.com\"],\r\n  \"app\": {\r\n    \"tailscale.com/cap/aperture\": [\\\r\n      { \"role\": \"admin\" },\\\r\n      { \"models\": \"**\" },\\\r\n    ],\r\n    // Custom capability — forwarded to hooks via \"grants\" send type\r\n    \"mycompany.com/cap/policy\": [\\\r\n      { \"tier\": \"enterprise\", \"department\": \"engineering\" },\\\r\n    ],\r\n  },\r\n},\n</code></pre>\n<p>When a hook includes <code>\"grants\"</code> in its <code>send</code> array, these custom capabilities appear in the hook metadata. External systems can use these capabilities to make authorization decisions.</p>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#grants-from-the-tailnet-policy-file\">Grants from the tailnet policy file</a></h4>\n<p>Specify grants directly in the tailnet policy file to use Tailscale's groups, tags, and device postures. This is the recommended approach for organizations that already manage access through Tailscale:</p>\n<pre><code class=\"language-json\">// In the tailnet policy file (not the Aperture config):\r\n\"grants\": [\\\r\n  {\\\r\n    \"src\": [\"group:engineering\"],\\\r\n    \"dst\": [\"tag:aperture\"],\\\r\n    \"app\": {\\\r\n      \"tailscale.com/cap/aperture\": [\\\r\n        { \"models\": \"**\" },\\\r\n        { \"role\": \"user\" },\\\r\n      ],\\\r\n    },\\\r\n  },\\\r\n  {\\\r\n    \"src\": [\"group:ml-team\"],\\\r\n    \"dst\": [\"tag:aperture\"],\\\r\n    \"app\": {\\\r\n      \"tailscale.com/cap/aperture\": [\\\r\n        { \"models\": \"**\" },\\\r\n        { \"role\": \"admin\" },\\\r\n      ],\\\r\n    },\\\r\n  },\\\r\n]\n</code></pre>\n<p>Aperture receives these capabilities from the Tailscale peer information at request time and merges them with any grants specified in the Aperture configuration.</p>\n<p>You can also scope grants based on Tailscale device postures. For example, grant access to different models or quotas, or apply different hooks, based on whether a user is on a managed corporate laptop versus an unmanaged VM (virtual machine).</p>\n<p>Groups (for example, <code>group:engineering</code>) aren't available for grants specified in the Aperture configuration. The Tailscale coordination server tracks group membership information and does not share it with Aperture. Grants specified in the Aperture configuration can match on individual login names or tags.</p>\n<h3><a href=\"https://tailscale.com/docs/aperture/configuration#quotas\"><code>quotas</code></a></h3>\n<p>The <code>quotas</code> section specifies dollar-based spending limits using token buckets. Each bucket has a capacity (maximum balance) and a refill rate. When a request's estimated cost would bring a bucket below zero, Aperture rejects the request with HTTP 429.</p>\n<p>For step-by-step instructions, refer to <a href=\"https://tailscale.com/docs/aperture/how-to/set-per-user-spending-limits\">Set per-user spending limits</a> and <a href=\"https://tailscale.com/docs/aperture/how-to/set-team-budget\">Set a team-wide budget</a>. To check and refill budgets, refer to <a href=\"https://tailscale.com/docs/aperture/how-to/check-and-refill-budgets\">Check and refill budgets</a>.</p>\n<pre><code class=\"language-json\">\"quotas\": {\r\n  // Per-user daily budget: refills $5/day, can burst up to $10\r\n  \"daily:&#x3C;user>\": {\r\n    \"capacity\": \"$10.00\",\r\n    \"rate\": \"$5.00/day\",\r\n    \"on_exceed\": \"reject\",\r\n  },\r\n\r\n  // Shared pool across all users who reference it\r\n  \"eng-team-pool\": {\r\n    \"capacity\": \"$100.00\",\r\n    \"rate\": \"$100.00/day\",\r\n    \"on_exceed\": \"reject\",\r\n  },\r\n\r\n  // Per-user limit for expensive models\r\n  \"opus:&#x3C;user>\": {\r\n    \"capacity\": \"$5.00\",\r\n    \"rate\": \"$2.50/day\",\r\n    \"on_exceed\": \"reject\",\r\n  },\r\n}\n</code></pre>\n<p>Each quota accepts the following fields:</p>\n<p>| Field | Format | Description |\r\n| --- | --- | --- |\r\n| <code>capacity</code> | <code>\"$&#x3C;amount>\"</code> | Maximum balance the bucket can hold. Also the starting balance. |\r\n| <code>rate</code> | <code>\"$&#x3C;amount>/&#x3C;unit>\"</code> | How fast the bucket refills. Units: <code>min</code>, <code>hour</code>, <code>day</code>, <code>week</code>, <code>month</code> (30 days). |\r\n| <code>on_exceed</code> | <code>\"reject\"</code> | Action when the bucket is empty. The supported value is <code>\"reject\"</code> (HTTP 429). |</p>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#template-variables\">Template variables</a></h4>\n<p>Quota names can include template variables that expand at request time:</p>\n<p>| Template | Expands to | Example |\r\n| --- | --- | --- |\r\n| <code>&#x3C;user></code> | Caller's Tailscale login name or tag combination | <code>daily:&#x3C;user></code> expands to <code>daily:alice@example.com</code> |\r\n| <code>&#x3C;node></code> | Caller's node ID (a distinct quota for each node, even if nodes share the same user or tag) | <code>device:&#x3C;node></code> expands to <code>device:nXXXXXXXXXCNTRL</code> |</p>\n<p>Quotas without a template variable (for example, <code>eng-team-pool</code>) create a single shared bucket.</p>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#attach-quotas-to-grants\">Attach quotas to grants</a></h4>\n<p>Quotas take effect only when attached to grants. A quota specified in the <code>quotas</code> section does nothing until a grant references it. When a request matches a grant, all quotas listed in that grant are charged at the same time:</p>\n<pre><code class=\"language-json\">\"grants\": [\\\r\n  {\\\r\n    \"src\": [\"*\"],\\\r\n    \"app\": {\\\r\n      \"tailscale.com/cap/aperture\": [\\\r\n        {\\\r\n          \"models\": \"**\",\\\r\n          \"quotas\": [\\\r\n            {\"bucket\": \"daily:&#x3C;user>\"},\\\r\n            {\"bucket\": \"eng-team-pool\"},\\\r\n          ],\\\r\n        },\\\r\n      ],\\\r\n    },\\\r\n  },\\\r\n]\n</code></pre>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#how-multiple-quotas-interact\">How multiple quotas interact</a></h4>\n<p>When a grant references multiple quota buckets, all buckets must have a positive balance for the request to proceed. If any single bucket is exhausted, Aperture rejects the request, even if other buckets have remaining balance.</p>\n<p>After the response completes, Aperture deducts the estimated cost from every referenced bucket at the same time.</p>\n<p>A request can match multiple grants, each with their own quotas. Aperture collects and enforces all matching quotas together.</p>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#quota-examples\">Quota examples</a></h4>\n<p>The following examples show common quota configurations.</p>\n<h5><a href=\"https://tailscale.com/docs/aperture/configuration#per-user-quota\">Per-user quota</a></h5>\n<p>Each user gets their own bucket with independent capacity and refill rate:</p>\n<pre><code class=\"language-json\">\"quotas\": {\r\n  \"daily:&#x3C;user>\": {\r\n    \"capacity\": \"$10.00\",\r\n    \"rate\": \"$5.00/day\",\r\n    \"on_exceed\": \"reject\",\r\n  },\r\n},\r\n\"grants\": [\\\r\n  {\\\r\n    \"src\": [\"*\"],\\\r\n    \"app\": {\\\r\n      \"tailscale.com/cap/aperture\": [\\\r\n        { \"role\": \"user\" },\\\r\n        { \"models\": \"**\",\\\r\n          \"quotas\": [{\"bucket\": \"daily:&#x3C;user>\"}] },\\\r\n      ],\\\r\n    },\\\r\n  },\\\r\n]\n</code></pre>\n<p>This creates a separate bucket for each user (for example, <code>daily:alice@example.com</code>, <code>daily:bob@example.com</code>), each with $10 capacity and a $5/day refill.</p>\n<h5><a href=\"https://tailscale.com/docs/aperture/configuration#quota-scoped-to-specific-models\">Quota scoped to specific models</a></h5>\n<p>Apply a quota only when the request targets specific models:</p>\n<pre><code class=\"language-json\">\"quotas\": {\r\n  \"opus:&#x3C;user>\": {\r\n    \"capacity\": \"$5.00\",\r\n    \"rate\": \"$2.50/day\",\r\n    \"on_exceed\": \"reject\",\r\n  },\r\n},\r\n\"grants\": [\\\r\n  {\\\r\n    \"src\": [\"*\"],\\\r\n    \"app\": {\\\r\n      \"tailscale.com/cap/aperture\": [\\\r\n        // General access — no quota\\\r\n        { \"models\": \"**\" },\\\r\n        // Additional quota for Opus models only\\\r\n        {\\\r\n          \"models\": \"*/claude-opus*\",\\\r\n          \"quotas\": [{\"bucket\": \"opus:&#x3C;user>\"}],\\\r\n        },\\\r\n      ],\\\r\n    },\\\r\n  },\\\r\n]\n</code></pre>\n<p>The <code>opus:&#x3C;user></code> quota applies only when the request targets an Opus model. Other models are unmetered.</p>\n<h5><a href=\"https://tailscale.com/docs/aperture/configuration#combine-per-user-and-shared-quotas\">Combine per-user and shared quotas</a></h5>\n<p>Charge each request against both a personal budget and a shared team pool:</p>\n<pre><code class=\"language-json\">\"quotas\": {\r\n  \"daily:&#x3C;user>\": {\r\n    \"capacity\": \"$10.00\",\r\n    \"rate\": \"$5.00/day\",\r\n    \"on_exceed\": \"reject\",\r\n  },\r\n  \"team-pool\": {\r\n    \"capacity\": \"$200.00\",\r\n    \"rate\": \"$200.00/day\",\r\n    \"on_exceed\": \"reject\",\r\n  },\r\n},\r\n\"grants\": [\\\r\n  {\\\r\n    \"src\": [\"*\"],\\\r\n    \"app\": {\\\r\n      \"tailscale.com/cap/aperture\": [\\\r\n        { \"role\": \"user\" },\\\r\n        {\\\r\n          \"models\": \"**\",\\\r\n          \"quotas\": [\\\r\n            {\"bucket\": \"daily:&#x3C;user>\"},\\\r\n            {\"bucket\": \"team-pool\"},\\\r\n          ],\\\r\n        },\\\r\n      ],\\\r\n    },\\\r\n  },\\\r\n]\n</code></pre>\n<p>Every request deducts from both the user's personal bucket and the shared team pool. Aperture blocks a user who exhausts their $10 daily limit even if the team pool has remaining budget.</p>\n<h3><a href=\"https://tailscale.com/docs/aperture/configuration#hooks\"><code>hooks</code></a></h3>\n<p>The <code>hooks</code> section specifies webhook endpoints that Aperture calls when conditions match. A unique string key identifies each hook, and grants reference this key. For step-by-step setup, refer to <a href=\"https://tailscale.com/docs/aperture/how-to/build-custom-webhook\">Build a custom webhook</a>. For integration-specific guides, refer to <a href=\"https://tailscale.com/docs/aperture/how-to/integrate-oso\">Integrate Oso with Aperture</a> or <a href=\"https://tailscale.com/docs/aperture/how-to/integrate-cerbos\">Integrate Cerbos with Aperture</a>.</p>\n<p>The following example shows the hooks configuration:</p>\n<pre><code class=\"language-json\">{\r\n  \"hooks\": {\r\n    \"oso\": {\r\n      \"url\": \"https://api.osohq.com/api/agents/v1/model-request\",\r\n      \"apikey\": \"YOUR_OSO_API_KEY\",\r\n      \"timeout\": \"10s\"\r\n    },\r\n    \"my-webhook\": {\r\n      \"url\": \"https://example.com/webhook\",\r\n      \"apikey\": \"YOUR_API_KEY\"\r\n    }\r\n  }\r\n}\n</code></pre>\n<p>Each hook configuration accepts the following fields:</p>\n<p>| Field | Type | Required | Default | Description |\r\n| --- | --- | --- | --- | --- |\r\n| <code>url</code> | string | Yes | N/A | HTTP or HTTPS endpoint to POST hook data to. |\r\n| <code>apikey</code> | string | No | <code>\"\"</code> | API key that Aperture sends to the hook endpoint using the method specified by <code>authorization</code>. |\r\n| <code>authorization</code> | string | No | <code>\"bearer\"</code> | How Aperture sends the API key. Supports the same values as provider authorization: <code>bearer</code>, <code>x-api-key</code>, <code>x-goog-api-key</code>. |\r\n| <code>timeout</code> | string | No | <code>\"5s\"</code> | Maximum duration to wait for the hook to respond. |\r\n| <code>disabled</code> | boolean | No | <code>false</code> | Skips this hook when Aperture would otherwise call it. Useful for temporarily disabling a hook without removing its configuration. |</p>\n<p>The <code>timeout</code> field accepts Go duration strings such as <code>5s</code>, <code>30s</code>, or <code>1m</code>. Set to <code>0</code> to disable the timeout.</p>\n<p>The <code>send_hooks</code> entries in the <code>grants</code> section trigger hooks. A hook specified here does nothing until a grant references it.</p>\n<h3><a href=\"https://tailscale.com/docs/aperture/configuration#hook-grants\">Hook grants</a></h3>\n<p>To trigger a hook, add a <code>send_hooks</code> entry to a capability in the <code>grants</code> section. Hook grants specify which requests trigger the hook and what data to send.</p>\n<pre><code class=\"language-json\">{\r\n  \"grants\": [\\\r\n    {\\\r\n      \"src\": [\"*\"],\\\r\n      \"app\": {\\\r\n        \"tailscale.com/cap/aperture\": [\\\r\n          {\\\r\n            \"models\": \"**\",\\\r\n            \"send_hooks\": [\\\r\n              {\\\r\n                \"name\": \"oso\",\\\r\n                \"events\": [\"tool_call_entire_request\"],\\\r\n                \"send\": [\"tools\", \"user_message\", \"request_body\", \"response_body\"],\\\r\n              },\\\r\n            ],\\\r\n          },\\\r\n        ],\\\r\n      },\\\r\n    },\\\r\n  ]\r\n}\n</code></pre>\n<p>Each <code>send_hooks</code> entry contains the following fields:</p>\n<p>| Field | Type | Description |\r\n| --- | --- | --- |\r\n| <code>name</code> | string | Key referencing a hook specified in the top-level <code>hooks</code> section. |\r\n| <code>events</code> | array | Event types that trigger the hook. |\r\n| <code>send</code> | array | List of data types to include in the hook payload. |</p>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#hook-events\">Hook events</a></h4>\n<p>| Event | Description |\r\n| --- | --- |\r\n| <code>tool_call_entire_request</code> | Fires once after the response completes if any message in the response contained tool calls. |\r\n| <code>entire_request</code> | Fires for every completed request. |</p>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#hook-send-types\">Hook send types</a></h4>\n<p>The <code>send</code> array specifies which data to include in the POST payload sent to the hook endpoint:</p>\n<p>| Field | Description |\r\n| --- | --- |\r\n| <code>tools</code> | Array of tool calls extracted from the response. |\r\n| <code>request_body</code> | The original request body sent to the LLM. |\r\n| <code>user_message</code> | The user's message from the request. |\r\n| <code>response_body</code> | The reconstructed response body JSON. |\r\n| <code>raw_responses</code> | Array of raw SSE messages (for streaming) or single response object. |\r\n| <code>estimated_cost</code> | Dollar cost estimate, pricing basis, and token usage breakdown. |\r\n| <code>grants</code> | Non-Aperture app capabilities from the user's grants (custom capabilities). |\r\n| <code>quotas</code> | Current state of all quota buckets that applied to this request. |</p>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#estimated_cost\"><code>estimated_cost</code></a></h4>\n<p>Includes the dollar cost estimate, the pricing basis used, and a token usage breakdown. These fields appear inside the <code>metadata</code> object of the hook payload:</p>\n<pre><code class=\"language-json\">{\r\n  \"models\": \"**\",\r\n  \"send_hooks\": [\\\r\n    {\\\r\n      \"name\": \"audit\",\\\r\n      \"events\": [\"tool_call_entire_request\"],\\\r\n      \"send\": [\"tools\", \"estimated_cost\"],\\\r\n    },\\\r\n  ],\r\n}\n</code></pre>\n<p>The hook receives the cost data inside <code>metadata</code>:</p>\n<pre><code class=\"language-json\">{\r\n  \"metadata\": {\r\n    \"login_name\": \"user@example.com\",\r\n    \"...\": \"...\",\r\n    \"estimated_cost\": {\r\n      \"dollars\": 0.0342,\r\n      \"cost_basis\": \"anthropic/claude-sonnet-4-5\",\r\n      \"usage\": {\r\n        \"input_tokens\": 1500,\r\n        \"output_tokens\": 800,\r\n        \"cached_tokens\": 200,\r\n        \"reasoning_tokens\": 0\r\n      }\r\n    }\r\n  },\r\n  \"tool_calls\": [...]\r\n}\n</code></pre>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#grants-1\"><code>grants</code></a></h4>\n<p>Includes any non-Aperture app capabilities from the user's grants. This lets external systems (policy engines, audit logs) access custom capabilities attached to the user. The <code>grants</code> data appears inside the <code>metadata</code> object:</p>\n<pre><code class=\"language-json\">{\r\n  \"src\": [\"alice@example.com\"],\r\n  \"app\": {\r\n    \"tailscale.com/cap/aperture\": [\\\r\n      { \"models\": \"**\" },\\\r\n      {\\\r\n        \"send_hooks\": [\\\r\n          {\\\r\n            \"name\": \"policy-engine\",\\\r\n            \"events\": [\"entire_request\"],\\\r\n            \"send\": [\"estimated_cost\", \"grants\"],\\\r\n          },\\\r\n        ],\\\r\n      },\\\r\n    ],\r\n    \"mycompany.com/cap/policy\": [\\\r\n      {\"tier\": \"enterprise\", \"max_context\": 200000},\\\r\n    ],\r\n  },\r\n}\n</code></pre>\n<p>The hook receives the custom capabilities inside <code>metadata</code>:</p>\n<pre><code class=\"language-json\">{\r\n  \"metadata\": {\r\n    \"login_name\": \"alice@example.com\",\r\n    \"...\": \"...\",\r\n    \"grants\": {\r\n      \"mycompany.com/cap/policy\": [\\\r\n        {\"tier\": \"enterprise\", \"max_context\": 200000}\\\r\n      ]\r\n    },\r\n    \"estimated_cost\": { \"...\" }\r\n  }\r\n}\n</code></pre>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#quotas-1\"><code>quotas</code></a></h4>\n<p>Includes the current state of all quota buckets that applied to the request. The <code>quotas</code> data appears inside the <code>metadata</code> object:</p>\n<pre><code class=\"language-json\">\"send\": [\"tools\", \"quotas\"]\n</code></pre>\n<p>The hook receives the bucket state inside <code>metadata</code>:</p>\n<pre><code class=\"language-json\">{\r\n  \"metadata\": {\r\n    \"login_name\": \"alice@example.com\",\r\n    \"...\": \"...\",\r\n    \"quotas\": {\r\n      \"daily:alice@example.com\": {\r\n        \"current\": 7250000000,\r\n        \"capacity\": 10000000000,\r\n        \"rate\": \"$5.00/day\"\r\n      }\r\n    }\r\n  }\r\n}\n</code></pre>\n<p>Values for <code>current</code> and <code>capacity</code> are in nanodollars (1 dollar = 1,000,000,000 nanodollars).</p>\n<p>Every hook call automatically includes a <code>metadata</code> object with request context:</p>\n<pre><code class=\"language-json\">{\r\n  \"metadata\": {\r\n    \"login_name\": \"user@example.com\",\r\n    \"user_agent\": \"curl/8.0\",\r\n    \"url\": \"/v1/chat/completions\",\r\n    \"model\": \"gpt-5\",\r\n    \"provider\": \"openai\",\r\n    \"tailnet_name\": \"example.com\",\r\n    \"stable_node_id\": \"n12345\",\r\n    \"request_id\": \"abc123\",\r\n    \"session_id\": \"oacc_1a2b3c4d5e6f7890\"\r\n  }\r\n}\n</code></pre>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#hook-grant-example\">Hook grant example</a></h4>\n<p>The following example sends tool call data and cost estimates to an audit service for all requests from a specific user:</p>\n<pre><code class=\"language-json\">{\r\n  \"grants\": [\\\r\n    {\\\r\n      \"src\": [\"developer@company.com\"],\\\r\n      \"app\": {\\\r\n        \"tailscale.com/cap/aperture\": [\\\r\n          {\\\r\n            \"models\": \"anthropic/**\",\\\r\n            \"send_hooks\": [\\\r\n              {\\\r\n                \"name\": \"my-webhook\",\\\r\n                \"events\": [\"tool_call_entire_request\"],\\\r\n                \"send\": [\"tools\", \"user_message\", \"estimated_cost\"],\\\r\n              },\\\r\n            ],\\\r\n          },\\\r\n          {\\\r\n            \"models\": \"openai/**\",\\\r\n            \"send_hooks\": [\\\r\n              {\\\r\n                \"name\": \"my-webhook\",\\\r\n                \"events\": [\"tool_call_entire_request\"],\\\r\n                \"send\": [\"tools\", \"user_message\", \"estimated_cost\"],\\\r\n              },\\\r\n            ],\\\r\n          },\\\r\n        ],\\\r\n      },\\\r\n    },\\\r\n  ]\r\n}\n</code></pre>\n<h3><a href=\"https://tailscale.com/docs/aperture/configuration#exporters\"><code>exporters</code></a></h3>\n<p>The <code>exporters</code> section configures periodic export of LLM session logs to external storage. Currently, Aperture supports exporting to S3-compatible storage (AWS S3, Google Cloud Storage, MinIO, Backblaze B2, and others). For step-by-step setup, refer to <a href=\"https://tailscale.com/docs/aperture/how-to/export-usage-data-to-s3\">Export usage data to S3</a>.</p>\n<p>The following example shows the exporters configuration:</p>\n<pre><code class=\"language-json\">{\r\n  \"exporters\": {\r\n    \"s3\": {\r\n      \"endpoint\": \"https://your-s3-compatible-endpoint.url\",\r\n      \"bucket_name\": \"aperture-exports\",\r\n      \"region\": \"us-east-1\",\r\n      \"prefix\": \"prod\",\r\n      \"access_key_id\": \"YOUR_AWS_KEY\",\r\n      \"access_secret\": \"YOUR_AWS_SECRET\",\r\n      \"every\": 3600,\r\n      \"limit\": 1000\r\n    }\r\n  }\r\n}\n</code></pre>\n<p>Setting <code>bucket_name</code> to a non-empty value enables the S3 exporter. Each S3 exporter configuration accepts the following fields:</p>\n<p>| Field | Type | Required | Default | Description |\r\n| --- | --- | --- | --- | --- |\r\n| <code>endpoint</code> | string | No | <code>\"\"</code> | HTTP endpoint for an S3-compatible API. Required for non-AWS services (GCS, MinIO, Backblaze B2). Omit for Amazon S3, where Aperture infers the endpoint from <code>region</code>. |\r\n| <code>bucket_name</code> | string | Conditional | <code>null</code> | Name of the S3 bucket to upload exports to. Setting this field to a non-empty value enables the S3 exporter. |\r\n| <code>region</code> | string | No | <code>\"us-east-1\"</code> | AWS region for the bucket. Required even for non-AWS endpoints because the AWS SDK validates this field. |\r\n| <code>prefix</code> | string | No | <code>\"\"</code> | Path prefix for new S3 objects. Must not end with <code>/</code>. |\r\n| <code>access_key_id</code> | string | Conditional | <code>null</code> | AWS access key ID used to authenticate. Required and must be non-empty when <code>bucket_name</code> is set and static credentials are used. |\r\n| <code>access_secret</code> | string | Conditional | <code>null</code> | Secret key used with <code>access_key_id</code> to authenticate. Required and must be non-empty when <code>access_key_id</code> is set. |\r\n| <code>every</code> | int | No | <code>3600</code> | Number of seconds to wait after the last export before starting another. Default is one hour. |\r\n| <code>limit</code> | int | No | <code>1000</code> | Maximum number of records per export. Aperture caps this value at <code>2500</code> and silently reduces higher values. |</p>\n<h3><a href=\"https://tailscale.com/docs/aperture/configuration#mcp\">MCP</a></h3>\n<p>Aperture's MCP server support is experimental. The MCP configuration syntax may change.</p>\n<p>The <code>mcp</code> section configures MCP (Model Context Protocol) server proxying. Aperture connects to remote MCP servers, aggregates their tools, resources, and resource templates, and exposes them through a single <code>/v1/mcp</code> endpoint. Refer to <a href=\"https://tailscale.com/docs/aperture/how-to/mcp-server\">MCP server proxying</a> for setup instructions and troubleshooting.</p>\n<pre><code class=\"language-json\">{\r\n  \"mcp\": {\r\n    \"accept_registrations\": true,\r\n    \"servers\": {\r\n      \"local\": {\r\n        \"url\": \"http://localhost:8185/v1/mcp\"\r\n      },\r\n      \"remote\": {\r\n        \"url\": \"http://mcp-server.example.ts.net:8080/v1/mcp\"\r\n      }\r\n    }\r\n  }\r\n}\n</code></pre>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#mcp-fields\"><code>mcp</code> fields</a></h4>\n<p>| Field | Type | Default | Description |\r\n| --- | --- | --- | --- |\r\n| <code>accept_registrations</code> | boolean | <code>false</code> | Allow backends to register dynamically through <code>POST /v1/mcp/register</code>. Backends POST <code>{\"url\": \"http://...\"}</code> and keep the connection open. Tools are unregistered when the connection closes. |\r\n| <code>servers</code> | map | <code>{}</code> | Map of server ID to server configuration. The map key is the server ID, which becomes the name prefix for tools (<code>serverID_toolname</code>), resources (<code>serverID-uri</code>), and resource templates (<code>serverID-uriTemplate</code>) from that backend. |</p>\n<h4><a href=\"https://tailscale.com/docs/aperture/configuration#servers-fields\"><code>servers</code> fields</a></h4>\n<p>Each entry in the <code>servers</code> map accepts the following fields:</p>\n<p>| Field | Type | Required | Description |\r\n| --- | --- | --- | --- |\r\n| <code>url</code> | string | Yes | The MCP server endpoint URL (for example, <code>http://localhost:8185/v1/mcp</code>). |</p>\n<h2><a href=\"https://tailscale.com/docs/aperture/configuration#quota-enforcement\">Quota enforcement</a></h2>\n<p>Aperture enforces quotas at request time by checking all referenced buckets before forwarding a request to the provider.</p>\n<h3><a href=\"https://tailscale.com/docs/aperture/configuration#what-happens-when-a-request-exceeds-a-quota\">What happens when a request exceeds a quota</a></h3>\n<p>When a request would exceed any of its quota buckets, Aperture:</p>\n<ol>\n<li>Rejects the request with HTTP status <strong>429 (Too Many Requests)</strong>.</li>\n<li>Sets a <code>Retry-After</code> header with the estimated seconds until enough budget refills.</li>\n<li>Formats the error to match the provider's native error format.</li>\n<li>Logs a warning with the bucket detail, login name, and model.</li>\n</ol>\n<p>The following table describes the provider-specific error formats:</p>\n<p>| Provider | Error format |\r\n| --- | --- |\r\n| Anthropic | <code>{\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\",\"message\":\"...\"}}</code> |\r\n| OpenAI | <code>{\"error\":{\"message\":\"...\",\"type\":\"insufficient_quota\",\"code\":\"insufficient_quota\"}}</code> |\r\n| Bedrock | <code>{\"message\":\"...\"}</code> with <code>x-amzn-ErrorType: ThrottlingException</code> |\r\n| Google/Vertex | <code>{\"error\":{\"code\":429,\"message\":\"...\",\"status\":\"RESOURCE_EXHAUSTED\"}}</code> |</p>\n<h3><a href=\"https://tailscale.com/docs/aperture/configuration#where-aperture-logs-enforcement\">Where Aperture logs enforcement</a></h3>\n<p>Aperture logs quota exceeded events at the <strong>Warn</strong> level in the server log with the following fields:</p>\n<ul>\n<li><code>detail</code>: Which buckets Aperture exhausted.</li>\n<li><code>login_name</code>: The user that Aperture blocked.</li>\n<li><code>model</code>: The <code>provider/model</code> that the user requested.</li>\n</ul>\n<h2><a href=\"https://tailscale.com/docs/aperture/configuration#validation\">Validation</a></h2>\n<p>Aperture validates configuration at load time and reports problems. Some issues are fatal errors that prevent the configuration from loading, while others are warnings that let the configuration load but should be addressed. The following table describes the validations.</p>\n<p>Aperture handles validation differently depending on how the configuration is loaded:</p>\n<ul>\n<li><strong>When Aperture loads configuration at startup or reload</strong>: Aperture logs warnings but loads the configuration successfully. This lets Aperture start even with minor issues.</li>\n<li><strong>When saving through the API or the Settings page of the Aperture dashboard</strong>: Aperture treats warnings as errors and rejects the save. This ensures that configurations saved through the UI are warning-free.</li>\n<li><strong>When using the validate endpoint (<code>POST /aperture/config:validate</code>)</strong>: Aperture surfaces warnings as validation errors (the response sets <code>Valid: false</code>), matching the save behavior.</li>\n</ul>\n<p>The admin lockout check only applies when saving — it prevents you from accidentally removing your own admin access.</p>\n<p>| Condition | Message | Severity |\r\n| --- | --- | --- |\r\n| No providers or MCP servers | <code>no providers or mcp servers defined; users will not be able to access any models</code> | Warning |\r\n| Provider missing <code>baseurl</code> | <code>provider {id} has no baseurl configured</code> | Warning |\r\n| Invalid <code>authorization</code> type | <code>provider {id} has invalid authorization type: {type}</code> | Warning |\r\n| Unresolved environment variable | <code>unsubstituted macros: [var_name]</code> | Error |\r\n| Invalid JSON or HUJSON syntax | Parse error details | Error |\r\n| Invalid quota definition | <code>quota {name}: {details}</code> | Error |\r\n| <strong>Structural / syntax</strong> |  |  |\r\n| Duplicate keys in config | <code>duplicate config key \"hostname\"</code> | Warning |\r\n| Unknown config keys | <code>unknown config key \"basurl\"</code> | Warning |\r\n| Type mismatch in field value | Field name and <code>json.UnmarshalTypeError</code> details | Warning |\r\n| <strong>Provider</strong> |  |  |\r\n| Invalid <code>add_headers</code> format | <code>provider {id}: add_headers entry \"Bad-Entry\" must be in \"Header-Name: value\" format</code> | Warning |\r\n| <strong>Quota</strong> |  |  |\r\n| Invalid quota name template | <code>quota \"{name}\": quota name \"{name}\" has unsupported template \"{template}\" after colon</code> | Warning |\r\n| <strong>Grant</strong> |  |  |\r\n| Unknown fields in grant | Strict JSON parsing error for the unrecognized field | Warning |\r\n| Grant <code>models</code> references undefined provider | <code>models pattern \"{pattern}\" references provider \"{name}\" which does not match any declared provider</code> | Warning |\r\n| Grant <code>mcp_tools</code>, <code>mcp_resources</code>, or <code>mcp_templates</code> references undefined MCP server | <code>mcp_tools pattern \"{pattern}\" references server \"{name}\" which does not match any declared MCP server</code> | Warning |\r\n| Grant <code>send_hooks</code> references undefined hook | <code>send_hooks references undefined hook \"{name}\"</code> | Warning |\r\n| Grant <code>quotas</code> references undefined quota | <code>quotas references undefined quota \"{name}\"</code> | Warning |\r\n| Invalid quota bucket ref template in grant | <code>grants[{n}] grant {n} quotas[{n}]: bucket ref \"{ref}\" has unsupported template...</code> | Warning |\r\n| Grant-level <code>add_headers</code> invalid format | <code>add_headers entry \"{entry}\" must be in \"Header-Name: value\" format</code> | Warning |\r\n| <strong>Hook</strong> |  |  |\r\n| Hook missing URL | <code>hook {name} has no url configured</code> | Warning |\r\n| Hook invalid URL scheme | <code>hook {name} has invalid URL scheme (must be http:// or https://): {url}</code> | Warning |\r\n| Hook invalid authorization type | <code>hook {name} has invalid authorization type: {type}</code> | Warning |\r\n| <strong>Exporter</strong> |  |  |\r\n| S3 prefix ends with <code>/</code> | <code>exporters.s3.prefix must not end with a slash</code> | Warning |\r\n| <strong>Structural warnings</strong> |  |  |\r\n| No grants assign admin role | <code>no grant in grants or temp_grants assigns role:admin; nobody will be able to manage this instance unless admin is granted via tsnet ACL policy</code> | Warning |\r\n| Providers configured but no grants defined | <code>providers are configured but no grants or temp_grants defined; all access will be denied</code> | Warning |\r\n| <strong>Save-only</strong> |  |  |\r\n| Empty configuration | <code>config must not be empty</code> | Error |\r\n| Admin lockout prevention | Rejects saves that would remove the saving user's admin access | Error |</p>\n<p>Aperture silently caps S3 exporter <code>limit_records</code> values above 2500 to 2500.</p>\n<p>For common validation issues and how to resolve them, refer to <a href=\"https://tailscale.com/docs/aperture/troubleshooting\">troubleshooting</a>.</p>\n<h2><a href=\"https://tailscale.com/docs/aperture/configuration#complete-example\">Complete example</a></h2>\n<p>The following example shows a complete configuration with all sections:</p>\n<pre><code class=\"language-json\">{\r\n  // Access control: who can use which models\r\n  \"grants\": [\\\r\n    // All users: access all models with per-user and org-wide quotas\\\r\n    {\\\r\n      \"src\": [\"*\"],\\\r\n      \"app\": {\\\r\n        \"tailscale.com/cap/aperture\": [\\\r\n          { \"role\": \"user\" },\\\r\n          {\\\r\n            \"models\": \"**\",\\\r\n            \"quotas\": [\\\r\n              {\"bucket\": \"daily:&#x3C;user>\"},\\\r\n              {\"bucket\": \"org-monthly\"},\\\r\n            ],\\\r\n          },\\\r\n        ],\\\r\n      },\\\r\n    },\\\r\n    // Admin access for specific user with audit hook\\\r\n    {\\\r\n      \"src\": [\"admin@company.com\"],\\\r\n      \"app\": {\\\r\n        \"tailscale.com/cap/aperture\": [\\\r\n          { \"role\": \"admin\" },\\\r\n          {\\\r\n            \"models\": \"**\",\\\r\n            \"send_hooks\": [\\\r\n              {\\\r\n                \"name\": \"oso\",\\\r\n                \"events\": [\"tool_call_entire_request\"],\\\r\n                \"send\": [\"tools\", \"estimated_cost\"],\\\r\n              },\\\r\n            ],\\\r\n          },\\\r\n        ],\\\r\n      },\\\r\n    },\\\r\n  ],\r\n\r\n  // Dollar-based spending limits\r\n  \"quotas\": {\r\n    \"daily:&#x3C;user>\": {\r\n      \"capacity\": \"$10.00\",\r\n      \"rate\": \"$5.00/day\",\r\n      \"on_exceed\": \"reject\",\r\n    },\r\n    \"org-monthly\": {\r\n      \"capacity\": \"$2000.00\",\r\n      \"rate\": \"$2000.00/month\",\r\n      \"on_exceed\": \"reject\",\r\n    },\r\n  },\r\n\r\n  // LLM session log export configuration\r\n  \"exporters\": {\r\n    \"s3\": {\r\n      // Required for S3-compatible services (GCS, MinIO, Backblaze B2, and others)\r\n      \"endpoint\": \"https://your-s3-compatible-endpoint.url\",\r\n      \"bucket_name\": \"aperture-exports\",\r\n      \"region\": \"us-west-2\",\r\n      \"prefix\": \"prod\",\r\n      \"access_key_id\": \"YOUR_AWS_KEY\",\r\n      \"access_secret\": \"YOUR_AWS_SECRET\",\r\n      \"every\": 3600,\r\n      \"limit\": 1000\r\n    }\r\n  },\r\n\r\n  // LLM providers\r\n  \"providers\": {\r\n    \"openai\": {\r\n      \"baseurl\": \"https://api.openai.com/\",\r\n      \"apikey\": \"YOUR_OPENAI_KEY\",\r\n      \"models\": [\"gpt-5\", \"gpt-5-mini\", \"gpt-4.1\"],\r\n      \"name\": \"OpenAI\",\r\n      \"description\": \"OpenAI models for coding and chat\",\r\n      \"compatibility\": {\r\n        \"openai_chat\": true,\r\n        \"openai_responses\": true\r\n      }\r\n    },\r\n    \"anthropic\": {\r\n      \"baseurl\": \"https://api.anthropic.com\",\r\n      \"apikey\": \"YOUR_PROXY_ANTHROPIC_KEY\",\r\n      \"authorization\": \"x-api-key\",\r\n      \"models\": [\"claude-sonnet-4-5\", \"claude-haiku-4-5\", \"claude-opus-4-5\"],\r\n      \"name\": \"Anthropic\",\r\n      \"compatibility\": {\r\n        \"openai_chat\": false,\r\n        \"anthropic_messages\": true\r\n      }\r\n    },\r\n    \"gemini\": {\r\n      \"baseurl\": \"https://generativelanguage.googleapis.com\",\r\n      \"apikey\": \"YOUR_PROXY_GEMINI_KEY\",\r\n      \"authorization\": \"x-goog-api-key\",\r\n      \"models\": [\"gemini-2.5-flash\", \"gemini-2.5-pro\"],\r\n      \"name\": \"Google Gemini\",\r\n      \"compatibility\": {\r\n        \"openai_chat\": false,\r\n        \"gemini_generate_content\": true\r\n      }\r\n    },\r\n    \"private\": {\r\n      \"baseurl\": \"YOUR_PRIVATE_LLM_URL\",\r\n      \"models\": [\"qwen3-coder-30b\"]\r\n    }\r\n  },\r\n\r\n  // Hooks for external integrations\r\n  \"hooks\": {\r\n    \"oso\": {\r\n      \"url\": \"https://api.osohq.com/api/agents/v1/model-request\",\r\n      \"apikey\": \"YOUR_OSO_API_KEY\",\r\n    },\r\n  },\r\n\r\n  // MCP server proxying\r\n  \"mcp\": {\r\n    \"accept_registrations\": true,\r\n    \"servers\": {\r\n      \"tools\": {\r\n        \"url\": \"http://mcp-tools.example.ts.net:8080/v1/mcp\",\r\n      },\r\n    },\r\n  },\r\n}\n</code></pre>\n<p><img src=\"https://cdn.brandfetch.io/tailscale.com/fallback/lettermark/theme/dark/h/256/w/256/icon?c=1bfwsmEH20zzEfSNTed\" alt=\"Project Logo\"></p>\n<p>Ask AI</p>\n<p>reCAPTCHA</p>\n<p>Recaptcha requires verification.</p>\n<p>protected by <strong>reCAPTCHA</strong></p>\n"}