Aperture by Tailscale configuration

Last validated:

Aperture by Tailscale is currently in alpha.

During the alpha testing period, Aperture by Tailscale is available at no additional cost across all Tailscale plans. Request access at aperture.tailscale.com. Aperture by Tailscale comes with six free users. Contact Tailscale for pricing if you need more than six users.

Aperture by Tailscale uses a configuration file to specify LLM providers, access control policies, and optional integrations. The configuration file 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 web interface using the Visual editor (default) or the JSON editor.

Minimal configuration

A minimal configuration requires at least one provider with a base URL and at least one model. The following example shows a minimal configuration:

{
  "providers": {
    "anthropic": {
      "baseurl": "https://api.anthropic.com",
      "apikey": "YOUR_ANTHROPIC_API_KEY",
      "models": [
        "claude-sonnet-4-5",
        "claude-opus-4-5",
      ],
      "authorization": "x-api-key",
      "compatibility": {
        "anthropic_messages": true,
      }
    }
  }
}

If 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.

The apikey field requires an API key from the provider's developer platform. Consumer and business subscription plans (such as Claude Pro or Claude Max, ChatGPT Plus, Pro, or Team, or Gemini Advanced) do not provide API keys and are not compatible with Aperture.

Default configuration

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:

{
    // The grants section uses the Tailscale ACL grant structure.
    "grants": [
        // Grant admin access (permission to see the settings and all other
        // users in the dashboard).
        {
            "src": [
                // Explicitly identify certain users by their Tailscale login.
                "example-user@example.com",

                // Grant admin access to everyone by default.
                // Remove this after you've configured explicit admin
                // access for yourself.
                // BE CAREFUL! If you remove this without granting explicit
                // admin access to yourself, you'll lose your ability
                // to edit this file.
                "*",
            ],
            "app": {
                "tailscale.com/cap/aperture": [
                    { "role": "admin" },
                ],
            },
        },

        // Every user who can access Aperture gets at least user-level access.
        // Remove this and Aperture denies access entirely by default.
        // Admin access in a separate grant takes precedence over this section.
        {
            "src": ["*"],
            "app": {
                "tailscale.com/cap/aperture": [
                    { "role": "user" },
                ],
            },
        },

        // Default: allow all users to access all models from all providers.
        // Without this grant, users can't access any models (deny by default).
        {
            "src": ["*"],
            "app": {
                "tailscale.com/cap/aperture": [
                    { "models": "**" },
                ],
            },
        },

        // This example hook sends traffic to Oso if it matches certain
        // parameters. Configure Oso in the "hooks" section for this to work.
        {
            "src": [
                // No users by default. Try "*" to capture everyone's traffic.
            ],
            "app": {
                "tailscale.com/cap/aperture": [
                    {
                        "send_hooks": [
                            {
                                "name": "oso",
                                // Capturing only tool calls
                                "events": ["tool_call_entire_request"],
                                "send": ["user_message", "tools", "request_body", "response_body"],
                            },
                        ],
                    },
                ],
            },
        },
    ],

    // Configure your LLM backends here.
    // Fill your API keys in below to share these providers with your team.
    // There's no limit to the number of providers you can configure.
    "providers": {
        "openai": {
            "baseurl": "https://api.openai.com",
            "name": "OpenAI",
            "apikey": "YOUR_OPENAI_API_KEY",
            "models": [
                "gpt-5",
                "gpt-5-mini",
                "gpt-5-nano",
                "gpt-4.1",
                "gpt-4.1-nano",
                "gpt-5.1-codex",
                "gpt-5.1-codex-max",
            ],
            "compatibility": {
                "openai_chat": true,
                "openai_responses": true,
                "anthropic_messages": false,
            },
        },
        "anthropic": {
            "baseurl": "https://api.anthropic.com",
            "name": "Anthropic",
            "apikey": "YOUR_ANTHROPIC_API_KEY",
            "models": [
                "claude-sonnet-4-5",
                "claude-sonnet-4-5-20250929",
                "claude-haiku-4-5",
                "claude-haiku-4-5-20251001",
                "claude-opus-4-5",
                "claude-opus-4-5-20251101",
            ],
            "compatibility": {
                "openai_chat": false,
                "openai_responses": false,
                "anthropic_messages": true,
            },
        },
    },

    // Hooks are configured API endpoints that Aperture calls under certain
    // conditions. The conditions themselves are configured in the
    // "grants" section.
    "hooks": {
        "oso": {
            "url":    "https://api.osohq.com/api/agents/v1/model-request",
            "apikey": "YOUR_OSO_API_KEY",
        },
    },
}

Configuration reference

The configuration file contains several top-level sections that control different aspects of Aperture's behavior. The following table describes the available top-level sections:

SectionRequiredDescription
providersYesMap of LLM provider configurations.
grantsNoAccess control policies for users, models, and quotas. Uses the Tailscale grant structure.
quotasNoDollar-based spending limits using token buckets.
hooksNoWebhook endpoint configurations.
exportersNoLLM session log export configuration. Currently supports S3-compatible storage.
auto_cost_basisNoBoolean (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.

providers

The 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:

{
  "providers": {
    "openai": { ... },
    "anthropic": { ... },
    "private": { ... }
  }
}

Each provider configuration accepts the following fields:

FieldTypeRequiredDefaultDescription
baseurlstringYesN/ABase URL for the provider's API.
modelsarrayYesN/AList of model IDs available from this provider.
apikeystringNo""API key for authentication.
authorizationstringNo"bearer"Authorization header type.
namestringNo""Display name for the UI.
descriptionstringNo""Description shown in the UI.
compatibilityobjectNoVaries by providerAPI compatibility flags.
cost_basisstringNoAuto-inferredOverride the pricing service used for cost estimation. Valid values: anthropic, openai, google, bedrock, bedrock-us, bedrock-eu, vertex, azure, azure-eu, openrouter, vercel.
model_cost_maparrayNo[]Map unknown model names to known models for pricing. Refer to model cost map.

The 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 for step-by-step configuration instructions.

Authorization types

Different providers require different authorization header formats. The authorization field specifies which format to use. The following table describes the available authorization types:

ValueHeader formatUsed by
bearerAuthorization: Bearer <key>OpenAI and most providers
x-api-keyx-api-key: <key>Anthropic
x-goog-api-keyx-goog-api-key: <key>Google Gemini

Provider compatibility

The compatibility object specifies which API formats the provider supports. This determines which endpoints Aperture exposes for the provider's models. The following table describes the compatibility fields:

FieldTypeDefaultDescription
openai_chatbooleantrueSupports /v1/chat/completions
openai_responsesbooleanfalseSupports /v1/responses
anthropic_messagesbooleanfalseSupports /v1/messages
gemini_generate_contentbooleanfalseSupports Gemini API format
bedrock_model_invokebooleanfalseSupports Amazon Bedrock format
google_generate_contentbooleanfalseSupports Vertex AI Gemini format
google_raw_predictbooleanfalseSupports Vertex AI raw predict for Anthropic models

Provider examples

The following examples show how to configure common providers.

OpenAI

Configure OpenAI with the chat and responses APIs:

{
  "providers": {
    "openai": {
      "baseurl": "https://api.openai.com/",
      "apikey": "YOUR_OPENAI_KEY",
      "models": ["gpt-5", "gpt-5-mini", "gpt-4.1"],
      "name": "OpenAI",
      "description": "OpenAI models",
      "compatibility": {
        "openai_chat": true,
        "openai_responses": true
      }
    }
  }
}
Amazon Bedrock

Configure Amazon Bedrock with the Bedrock model invocation API:

{
  "providers": {
    "bedrock": {
      "baseurl": "https://bedrock-runtime.us-east-1.amazonaws.com",
      "apikey": "bedrock-api-key-xxx",
      "authorization": "bearer",
      "models": [
        "us.anthropic.claude-haiku-4-5-20251001-v1:0",
        "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
        "us.anthropic.claude-opus-4-5-20251101-v1:0",
        "us.anthropic.claude-opus-4-6-v1"
      ],
      "compatibility": {
        "bedrock_model_invoke": true
      }
    }
  }
}
Anthropic

Configure Anthropic with the messages API and x-api-key authorization:

{
  "providers": {
    "anthropic": {
      "baseurl": "https://api.anthropic.com",
      "apikey": "YOUR_ANTHROPIC_KEY",
      "authorization": "x-api-key",
      "models": ["claude-sonnet-4-5", "claude-haiku-4-5", "claude-opus-4-5"],
      "compatibility": {
        "openai_chat": false,
        "anthropic_messages": true
      }
    }
  }
}
Google Gemini

Configure Google Gemini with the Gemini API and x-goog-api-key authorization:

{
  "providers": {
    "gemini": {
      "baseurl": "https://generativelanguage.googleapis.com",
      "apikey": "YOUR_GEMINI_KEY",
      "authorization": "x-goog-api-key",
      "models": ["gemini-2.5-flash", "gemini-2.5-pro"],
      "name": "Google Gemini",
      "compatibility": {
        "openai_chat": false,
        "gemini_generate_content": true
      }
    }
  }
}
Google Vertex AI

Configure Google Vertex AI with support for both Gemini models and Anthropic models with raw predict:

{
  "providers": {
    "vertex": {
      "baseurl": "https://aiplatform.googleapis.com",
      "authorization": "bearer",
      "apikey": "keyfile::ba3..3kb.data...67",
      "models": [
        "gemini-2.0-flash-exp",
        "gemini-2.5-flash",
        "gemini-2.5-flash-image",
        "gemini-2.5-pro",
        "claude-opus-4-5@20251101",
        "claude-haiku-4-5@20251001",
        "claude-sonnet-4-5@20250929",
        "claude-opus-4-6"
      ],
      "compatibility": {
        // Gemini model support
        "google_generate_content": true,
        // Anthropic via Vertex model support
        "google_raw_predict": true
      }
    }
  }
}

For step-by-step setup including GCP service account creation and key file generation, refer to Set up a Vertex AI provider.

OpenRouter

Configure OpenRouter as a multi-provider aggregator:

{
  "providers": {
    "openrouter": {
      "baseurl": "https://openrouter.ai/api/",
      "apikey": "YOUR_OPENROUTER_KEY",
      "models": [
        "qwen/qwen3-235b-a22b-2507",
        "google/gemini-2.5-pro-preview",
        "x-ai/grok-code-fast-1"
      ]
    }
  }
}
Self-hosted LLM

Configure a self-hosted LLM server accessible from the tailnet:

{
  "providers": {
    "private": {
      "baseurl": "YOUR_PRIVATE_LLM_URL",
      "models": ["qwen3-coder-30b", "llama-3.1-70b"]
    }
  }
}

Pricing and quotas

Pricing and quotas enable Aperture to estimate the dollar cost of every LLM request. Cost estimates power quotas, hook metadata, and the per-model pricing shown in the UI.

Aperture auto-infers pricing for known providers based on the provider's compatibility flags (for example, anthropic_messages maps to Anthropic pricing). For providers where auto-inference does not apply, configure pricing explicitly.

Explicit cost_basis

If a provider's ID does not match a known pricing service, or to override the auto-inferred pricing, set cost_basis on the provider:

"providers": {
  // Provider ID "my-mirror" doesn't match any known service,
  // but it serves Anthropic models at Anthropic prices.
  "my-mirror": {
    "baseurl": "https://mirror.internal/",
    "apikey": "sk-xxx",
    "models": ["claude-sonnet-4-5", "claude-opus-4-6"],
    "compatibility": { "anthropic_messages": true },
    // Tell Aperture to price these like Anthropic models:
    "cost_basis": "anthropic",
  },
}

Aperture supports the following cost_basis values: anthropic, openai, google, bedrock, bedrock-us, bedrock-eu, vertex, azure, azure-eu, openrouter, vercel.

Model cost map

When a model name does not appear in the pricing database (for example, a new or custom model), use model_cost_map to map it to a known model for pricing purposes:

"anthropic": {
  "cost_basis": "anthropic",
  "model_cost_map": [
    // claude-opus-9-0 isn't in the pricing DB yet;
    // price it like claude-opus-4-6
    {"match": "claude-opus-9-*", "as": "claude-opus-4-6"},

    // Preview models priced like sonnet
    {"match": "claude-*-preview*", "as": "claude-sonnet-4-5",
     "adjustment": 1.1},
  ],
}

Each entry supports the following fields:

  • match: Glob pattern against the model name (uses path.Match syntax).
  • as: Replacement model name for the pricing lookup.
  • adjustment: Price multiplier (optional, default 1.0). Use 1.5 to mark up 50%.

The first matching entry wins.

Where to find cost data

Aperture surfaces cost data in several places:

  • Models page: Each model shows per-million-token pricing (input/output) with a tooltip that includes cache, reasoning, image, and web search rates.
  • CSV export: The Adoption Dashboard's Download CSV button exports usage data including token counts per model, user, and date.
  • 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 for details.

grants

The 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 with capabilities scoped under "tailscale.com/cap/aperture". Aperture is deny-by-default: without a matching grant, a user cannot access any models.

The 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.

You can specify grants in two places:

  1. Aperture configuration: In the "grants" array (described below).
  2. Tailnet policy file: As app capabilities under "tailscale.com/cap/aperture", delivered to Aperture automatically through the Tailscale coordination server.

Aperture merges grants from both sources additively. Roles escalate (user to admin) but never downgrade.

Basic structure

A grant specifies a source (src) and a set of app capabilities:

"grants": [
  {
    "src": ["*"],               // who this grant applies to
    "app": {
      "tailscale.com/cap/aperture": [
        // array of individual capabilities
        { "models": "**" },     // allow access to all models
      ],
    },
  },
]

Source match (src)

The src field determines which users a grant applies to:

  • "*": Matches everyone.
  • "alice@example.com": Matches a specific Tailscale login name.
  • "(loopback)": Matches local/loopback requests (useful for development).

Model access

The models field uses fully-qualified provider/model glob patterns:

PatternMatches
"**"All models from all providers
"anthropic/**"All Anthropic models
"openai/gpt-5"Exactly openai/gpt-5
"*/claude-sonnet*"Any claude-sonnet* model from any single provider
"aperture-*/**"Any model from a provider whose name starts with aperture-

* matches a single path segment. ** matches zero or more segments.

A grant with no models field is "floating," meaning it applies globally (useful for hooks and quotas that apply regardless of model).

Role assignment

Roles determine a user's permission level:

{ "role": "admin" }   // full admin access
{ "role": "user" }    // standard user access

Without a role grant, the user cannot access Aperture. If multiple grants match a given user, the highest-permissioned role (admin) wins.

MCP access

Aperture's MCP server support is experimental. The MCP grants syntax may change.

Grant access to registered MCP items in the same way as models:

{
  "mcp_tools": "local/*",       // tools from the "local" MCP server
  "mcp_resources": "**",        // all resources from all servers
  "mcp_templates": "remote/*",  // templates from "remote" server
}

Custom app capabilities

Grants can include capability keys beyond tailscale.com/cap/aperture. Aperture passes these through to hooks when you include the "grants" send type:

{
  "src": ["admin@example.com"],
  "app": {
    "tailscale.com/cap/aperture": [
      { "role": "admin" },
      { "models": "**" },
    ],
    // Custom capability — forwarded to hooks via "grants" send type
    "mycompany.com/cap/policy": [
      { "tier": "enterprise", "department": "engineering" },
    ],
  },
},

When a hook includes "grants" in its send array, these custom capabilities appear in the hook metadata, allowing external systems to make authorization decisions based on them.

Grants from the tailnet policy file

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:

// In the tailnet policy file (not the Aperture config):
"grants": [
  {
    "src": ["group:engineering"],
    "dst": ["tag:aperture"],
    "app": {
      "tailscale.com/cap/aperture": [
        { "models": "**" },
        { "role": "user" },
      ],
    },
  },
  {
    "src": ["group:ml-team"],
    "dst": ["tag:aperture"],
    "app": {
      "tailscale.com/cap/aperture": [
        { "models": "**" },
        { "role": "admin" },
      ],
    },
  },
]

Aperture receives these capabilities from the Tailscale peer information at request time and merges them with any grants specified in the Aperture configuration.

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).

Groups (for example, group:engineering) aren't available for grants specified in the Aperture configuration file. The Tailscale coordination server tracks group membership information and does not share it with Aperture. Grants specified in the Aperture configuration file can match on individual login names or tags.

quotas

The 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.

"quotas": {
  // Per-user daily budget: refills $5/day, can burst up to $10
  "daily:<user>": {
    "capacity": "$10.00",
    "rate": "$5.00/day",
    "on_exceed": "reject",
  },

  // Shared pool across all users who reference it
  "eng-team-pool": {
    "capacity": "$100.00",
    "rate": "$100.00/day",
    "on_exceed": "reject",
  },

  // Per-user limit for expensive models
  "opus:<user>": {
    "capacity": "$5.00",
    "rate": "$2.50/day",
    "on_exceed": "reject",
  },
}

Each quota accepts the following fields:

FieldFormatDescription
capacity"$<amount>"Maximum balance the bucket can hold. Also the starting balance.
rate"$<amount>/<unit>"How fast the bucket refills. Units: min, hour, day, week, month (30 days).
on_exceed"reject"Action when the bucket is empty. The supported value is "reject" (HTTP 429).

Template variables

Quota names can include template variables that expand at request time:

TemplateExpands toExample
<user>Caller's Tailscale login name or tag combinationdaily:<user> expands to daily:alice@example.com
<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

Quotas without a template variable (for example, eng-team-pool) create a single shared bucket.

Attach quotas to grants

Quotas 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:

"grants": [
  {
    "src": ["*"],
    "app": {
      "tailscale.com/cap/aperture": [
        {
          "models": "**",
          "quotas": [
            {"bucket": "daily:<user>"},
            {"bucket": "eng-team-pool"},
          ],
        },
      ],
    },
  },
]

How multiple quotas interact

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, the request is rejected, even if other buckets have remaining balance.

After the response completes, Aperture deducts the estimated cost from every referenced bucket at the same time.

A request can match multiple grants, each with their own quotas. Aperture collects and enforces all matching quotas together.

Quota examples

The following examples show common quota configurations.

Per-user quota

Each user gets their own bucket with independent capacity and refill rate:

"quotas": {
  "daily:<user>": {
    "capacity": "$10.00",
    "rate": "$5.00/day",
    "on_exceed": "reject",
  },
},
"grants": [
  {
    "src": ["*"],
    "app": {
      "tailscale.com/cap/aperture": [
        { "role": "user" },
        { "models": "**",
          "quotas": [{"bucket": "daily:<user>"}] },
      ],
    },
  },
]

This 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.

Quota scoped to specific models

Apply a quota only when the request targets specific models:

"quotas": {
  "opus:<user>": {
    "capacity": "$5.00",
    "rate": "$2.50/day",
    "on_exceed": "reject",
  },
},
"grants": [
  {
    "src": ["*"],
    "app": {
      "tailscale.com/cap/aperture": [
        // General access — no quota
        { "models": "**" },
        // Additional quota for Opus models only
        {
          "models": "*/claude-opus*",
          "quotas": [{"bucket": "opus:<user>"}],
        },
      ],
    },
  },
]

The opus:<user> quota applies only when the request targets an Opus model. Other models are unmetered.

Combine per-user and shared quotas

Charge each request against both a personal budget and a shared team pool:

"quotas": {
  "daily:<user>": {
    "capacity": "$10.00",
    "rate": "$5.00/day",
    "on_exceed": "reject",
  },
  "team-pool": {
    "capacity": "$200.00",
    "rate": "$200.00/day",
    "on_exceed": "reject",
  },
},
"grants": [
  {
    "src": ["*"],
    "app": {
      "tailscale.com/cap/aperture": [
        { "role": "user" },
        {
          "models": "**",
          "quotas": [
            {"bucket": "daily:<user>"},
            {"bucket": "team-pool"},
          ],
        },
      ],
    },
  },
]

Every request deducts from both the user's personal bucket and the shared team pool. A user who exhausts their $10 daily limit is blocked even if the team pool has remaining budget.

hooks

The hooks section specifies webhook endpoints that Aperture calls when conditions match. A unique string key identifies each hook, and grants reference this key. The following example shows the hooks configuration:

{
  "hooks": {
    "oso": {
      "url": "https://api.osohq.com/api/agents/v1/model-request",
      "apikey": "YOUR_OSO_API_KEY",
      "timeout": "10s"
    },
    "my-webhook": {
      "url": "https://example.com/webhook",
      "apikey": "YOUR_API_KEY"
    }
  }
}

Each hook configuration accepts the following fields:

FieldTypeRequiredDefaultDescription
urlstringYesN/AHTTP or HTTPS endpoint to POST hook data to.
apikeystringNo""API key sent to the hook endpoint using the method specified by authorization.
authorizationstringNo"bearer"How the API key is sent. Supports the same values as provider authorization: bearer, x-api-key, x-goog-api-key.
timeoutstringNo"5s"Maximum duration to wait for the hook to respond.
disabledbooleanNofalseSkip this hook when it would otherwise be called. Useful for temporarily disabling a hook without removing its configuration.

The timeout field accepts Go duration strings such as 5s, 30s, or 1m. Set to 0 to disable the timeout.

The send_hooks entries in the grants section trigger hooks. A hook specified here does nothing until a grant references it.

Hook grants

To 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.

{
  "grants": [
    {
      "src": ["*"],
      "app": {
        "tailscale.com/cap/aperture": [
          {
            "models": "**",
            "send_hooks": [
              {
                "name": "oso",
                "events": ["tool_call_entire_request"],
                "send": ["tools", "user_message", "request_body", "response_body"],
              },
            ],
          },
        ],
      },
    },
  ]
}

Each send_hooks entry contains the following fields:

FieldTypeDescription
namestringKey referencing a hook specified in the top-level hooks section.
eventsarrayEvent types that trigger the hook.
sendarrayList of data types to include in the hook payload.

Hook events

EventDescription
tool_call_entire_requestFires once after the response completes if any message in the response contained tool calls.
entire_requestFires for every completed request.

Hook send types

The send array specifies which data to include in the POST payload sent to the hook endpoint:

FieldDescription
toolsArray of tool calls extracted from the response.
request_bodyThe original request body sent to the LLM.
user_messageThe user's message from the request.
response_bodyThe reconstructed response body JSON.
raw_responsesArray of raw SSE messages (for streaming) or single response object.
estimated_costDollar cost estimate, pricing basis, and token usage breakdown.
grantsNon-Aperture app capabilities from the user's grants (custom capabilities).
quotasCurrent state of all quota buckets that applied to this request.

estimated_cost

Includes the dollar cost estimate, the pricing basis used, and a token usage breakdown. These fields appear inside the metadata object of the hook payload:

{
  "models": "**",
  "send_hooks": [
    {
      "name": "audit",
      "events": ["tool_call_entire_request"],
      "send": ["tools", "estimated_cost"],
    },
  ],
}

The hook receives the cost data inside metadata:

{
  "metadata": {
    "login_name": "user@example.com",
    "...": "...",
    "estimated_cost": {
      "dollars": 0.0342,
      "cost_basis": "anthropic/claude-sonnet-4-5",
      "usage": {
        "input_tokens": 1500,
        "output_tokens": 800,
        "cached_tokens": 200,
        "reasoning_tokens": 0
      }
    }
  },
  "tool_calls": [...]
}

grants

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 grants data appears inside the metadata object:

{
  "src": ["alice@example.com"],
  "app": {
    "tailscale.com/cap/aperture": [
      { "models": "**" },
      {
        "send_hooks": [
          {
            "name": "policy-engine",
            "events": ["entire_request"],
            "send": ["estimated_cost", "grants"],
          },
        ],
      },
    ],
    "mycompany.com/cap/policy": [
      {"tier": "enterprise", "max_context": 200000},
    ],
  },
}

The hook receives the custom capabilities inside metadata:

{
  "metadata": {
    "login_name": "alice@example.com",
    "...": "...",
    "grants": {
      "mycompany.com/cap/policy": [
        {"tier": "enterprise", "max_context": 200000}
      ]
    },
    "estimated_cost": { "..." }
  }
}

quotas

Includes the current state of all quota buckets that applied to the request. The quotas data appears inside the metadata object:

"send": ["tools", "quotas"]

The hook receives the bucket state inside metadata:

{
  "metadata": {
    "login_name": "alice@example.com",
    "...": "...",
    "quotas": {
      "daily:alice@example.com": {
        "current": 7250000000,
        "capacity": 10000000000,
        "rate": "$5.00/day"
      }
    }
  }
}

Values for current and capacity are in nanodollars (1 dollar = 1,000,000,000 nanodollars).

Every hook call automatically includes a metadata object with request context:

{
  "metadata": {
    "login_name": "user@example.com",
    "user_agent": "curl/8.0",
    "url": "/v1/chat/completions",
    "model": "gpt-5",
    "provider": "openai",
    "tailnet_name": "example.com",
    "stable_node_id": "n12345",
    "request_id": "abc123",
    "session_id": "oacc_1a2b3c4d5e6f7890"
  }
}

Hook grant example

The following example sends tool call data and cost estimates to an audit service for all requests from a specific user:

{
  "grants": [
    {
      "src": ["developer@company.com"],
      "app": {
        "tailscale.com/cap/aperture": [
          {
            "models": "anthropic/** openai/**",
            "send_hooks": [
              {
                "name": "my-webhook",
                "events": ["tool_call_entire_request"],
                "send": ["tools", "user_message", "estimated_cost"],
              },
            ],
          },
        ],
      },
    },
  ]
}

exporters

The 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). The following example shows the exporters configuration:

{
  "exporters": {
    "s3": {
      "endpoint": "https://your-s3-compatible-endpoint.url",
      "bucket_name": "aperture-exports",
      "region": "us-east-1",
      "prefix": "prod",
      "access_key_id": "YOUR_AWS_KEY",
      "access_secret": "YOUR_AWS_SECRET",
      "every": 3600,
      "limit": 1000
    }
  }
}

Setting bucket_name to a non-empty value enables the S3 exporter. Each S3 exporter configuration accepts the following fields:

FieldTypeRequiredDefaultDescription
endpointstringNo""HTTP endpoint for an S3-compatible API. Required for non-AWS services (GCS, MinIO, Backblaze B2). Omit for AWS S3, where Aperture infers the endpoint from region.
bucket_namestringConditionalnullName of the S3 bucket to upload exports to. Setting this field to a non-empty value enables the S3 exporter.
regionstringNo"us-east-1"AWS region for the bucket. Required even for non-AWS endpoints because the AWS SDK validates this field.
prefixstringNo""Path prefix for new S3 objects. Must not end with /.
access_key_idstringConditionalnullAWS access key ID used to authenticate. Required and must be non-empty when bucket_name is set and static credentials are used.
access_secretstringConditionalnullSecret key used with access_key_id to authenticate. Required and must be non-empty when access_key_id is set.
everyintNo3600Number of seconds to wait after the last export before starting another. Default is one hour.
limitintNo1000Maximum number of records per export. Aperture caps this value at 2500 and silently reduces higher values.

Quota enforcement

Aperture enforces quotas at request time by checking all referenced buckets before forwarding a request to the provider.

What happens when a request exceeds a quota

When a request would exceed any of its quota buckets, Aperture:

  1. Rejects the request with HTTP status 429 (Too Many Requests).
  2. Sets a Retry-After header with the estimated seconds until enough budget refills.
  3. Formats the error to match the provider's native error format.
  4. Logs a warning with the bucket detail, login name, and model.

The following table describes the provider-specific error formats:

ProviderError format
Anthropic{"type":"error","error":{"type":"rate_limit_error","message":"..."}}
OpenAI{"error":{"message":"...","type":"insufficient_quota","code":"insufficient_quota"}}
Bedrock{"message":"..."} with x-amzn-ErrorType: ThrottlingException
Google/Vertex{"error":{"code":429,"message":"...","status":"RESOURCE_EXHAUSTED"}}

Where Aperture logs enforcement

Aperture logs quota exceeded events at the Warn level in the server log with the following fields:

  • detail: Which buckets were exhausted.
  • login_name: The user who was blocked.
  • model: The provider/model that was requested.

Validation

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 allow the configuration to load but should be addressed. The following table describes the validations.

Aperture handles validation differently depending on how the configuration is loaded:

  • When Aperture loads configuration at startup or reload: Warnings are logged but the configuration loads successfully. This lets Aperture start even with minor issues.
  • When saving through the API or settings page: Warnings are treated as errors and the save is rejected. This ensures that configurations saved through the UI are warning-free.
  • When using the validate endpoint (POST /aperture/config:validate): Warnings are also surfaced as validation errors (the response sets Valid: false), matching the save behavior.

The admin lockout check only applies when saving — it prevents you from accidentally removing your own admin access.

ConditionMessageSeverity
No providers or MCP serversno providers or mcp servers defined; users will not be able to access any modelsWarning
Provider missing baseurlprovider {id} has no baseurl configuredWarning
Invalid authorization typeprovider {id} has invalid authorization type: {type}Warning
Unresolved environment variableunsubstituted macros: [var_name]Error
Invalid JSON or HUJSON syntaxParse error detailsError
Invalid quota definitionquota {name}: {details}Error
Structural / syntax
Duplicate keys in configduplicate config key "hostname"Warning
Unknown config keysunknown config key "basurl"Warning
Type mismatch in field valueField name and json.UnmarshalTypeError detailsWarning
Provider
Invalid add_headers formatprovider {id}: add_headers entry "Bad-Entry" must be in "Header-Name: value" formatWarning
Quota
Invalid quota name templatequota "{name}": quota name "{name}" has unsupported template "{template}" after colonWarning
Grant
Unknown fields in grantStrict JSON parsing error for the unrecognized fieldWarning
Grant models references undefined providermodels pattern "{pattern}" references provider "{name}" which does not match any declared providerWarning
Grant mcp_tools, mcp_resources, or mcp_templates references undefined MCP servermcp_tools pattern "{pattern}" references server "{name}" which does not match any declared MCP serverWarning
Grant send_hooks references undefined hooksend_hooks references undefined hook "{name}"Warning
Grant quotas references undefined quotaquotas references undefined quota "{name}"Warning
Invalid quota bucket ref template in grantgrants[{n}] grant {n} quotas[{n}]: bucket ref "{ref}" has unsupported template...Warning
Grant-level add_headers invalid formatadd_headers entry "{entry}" must be in "Header-Name: value" formatWarning
Hook
Hook missing URLhook {name} has no url configuredWarning
Hook invalid URL schemehook {name} has invalid URL scheme (must be http:// or https://): {url}Warning
Hook invalid authorization typehook {name} has invalid authorization type: {type}Warning
Exporter
S3 prefix ends with /exporters.s3.prefix must not end with a slashWarning
Structural warnings
No grants assign admin roleno grant in grants or temp_grants assigns role:admin; nobody will be able to manage this instance unless admin is granted via tsnet ACL policyWarning
Providers configured but no grants definedproviders are configured but no grants or temp_grants defined; all access will be deniedWarning
Save-only
Empty configurationconfig must not be emptyError
Admin lockout preventionRejects saves that would remove the saving user's admin accessError

S3 exporter limit_records values above 2500 are silently capped to 2500.

For common validation issues and how to resolve them, see Troubleshooting.

Complete example

The following example shows a complete configuration with all sections:

{
  // Access control: who can use which models
  "grants": [
    // All users: access all models with per-user and org-wide quotas
    {
      "src": ["*"],
      "app": {
        "tailscale.com/cap/aperture": [
          { "role": "user" },
          {
            "models": "**",
            "quotas": [
              {"bucket": "daily:<user>"},
              {"bucket": "org-monthly"},
            ],
          },
        ],
      },
    },
    // Admin access for specific user with audit hook
    {
      "src": ["admin@company.com"],
      "app": {
        "tailscale.com/cap/aperture": [
          { "role": "admin" },
          {
            "models": "**",
            "send_hooks": [
              {
                "name": "oso",
                "events": ["tool_call_entire_request"],
                "send": ["tools", "estimated_cost"],
              },
            ],
          },
        ],
      },
    },
  ],

  // Dollar-based spending limits
  "quotas": {
    "daily:<user>": {
      "capacity": "$10.00",
      "rate": "$5.00/day",
      "on_exceed": "reject",
    },
    "org-monthly": {
      "capacity": "$2000.00",
      "rate": "$2000.00/month",
      "on_exceed": "reject",
    },
  },

  // LLM session log export configuration
  "exporters": {
    "s3": {
      // Required for S3-compatible services (GCS, MinIO, Backblaze B2, and others)
      "endpoint": "https://your-s3-compatible-endpoint.url",
      "bucket_name": "aperture-exports",
      "region": "us-west-2",
      "prefix": "prod",
      "access_key_id": "YOUR_AWS_KEY",
      "access_secret": "YOUR_AWS_SECRET",
      "every": 3600,
      "limit": 1000
    }
  },

  // LLM providers
  "providers": {
    "openai": {
      "baseurl": "https://api.openai.com/",
      "apikey": "YOUR_OPENAI_KEY",
      "models": ["gpt-5", "gpt-5-mini", "gpt-4.1"],
      "name": "OpenAI",
      "description": "OpenAI models for coding and chat",
      "compatibility": {
        "openai_chat": true,
        "openai_responses": true
      }
    },
    "anthropic": {
      "baseurl": "https://api.anthropic.com",
      "apikey": "YOUR_PROXY_ANTHROPIC_KEY",
      "authorization": "x-api-key",
      "models": ["claude-sonnet-4-5", "claude-haiku-4-5", "claude-opus-4-5"],
      "name": "Anthropic",
      "compatibility": {
        "openai_chat": false,
        "anthropic_messages": true
      }
    },
    "gemini": {
      "baseurl": "https://generativelanguage.googleapis.com",
      "apikey": "YOUR_PROXY_GEMINI_KEY",
      "authorization": "x-goog-api-key",
      "models": ["gemini-2.5-flash", "gemini-2.5-pro"],
      "name": "Google Gemini",
      "compatibility": {
        "openai_chat": false,
        "gemini_generate_content": true
      }
    },
    "private": {
      "baseurl": "YOUR_PRIVATE_LLM_URL",
      "models": ["qwen3-coder-30b"]
    }
  },

  // Hooks for external integrations
  "hooks": {
    "oso": {
      "url": "https://api.osohq.com/api/agents/v1/model-request",
      "apikey": "YOUR_OSO_API_KEY",
    },
  },
}