Table of Contents

Return to the Hermes Agent and Local AI Course

One private base URL does not mean one wire format. Tailscale Aperture exposes a single gateway host on your tailnet. Claude Code and OpenCode each point at the same host, yet each sends a different request format. The host stays constant. The path and the wire protocol change per tool.

Cursor is the exception in this lineup. It routes every request through Cursor’s own cloud and rejects private or LAN addresses, so it cannot reach a tailnet-private Aperture host.

This guide configures each agent against the same Aperture host, names the exact format each one speaks, and gives a one-line check for each. Every configuration below is verified against vendor documentation and marked untested, because no live request was run through a gateway during research.

Reader outcome: point Claude Code and OpenCode at one Aperture host, understand why Cursor needs a public edge instead, and run one verification command per tool.

Key Takeaways

  • The host is the single constant. Aperture exposes one URL. Each agent appends its own path.
  • Claude Code speaks Anthropic Messages only. It needs a /v1/messages endpoint, not OpenAI /v1/chat/completions.
  • OpenCode speaks OpenAI Chat Completions natively against the same host.
  • Cursor stays cloud-routed. It needs a public OpenAI-compatible endpoint, so a private Aperture host is out of reach.
  • Keyless on the tailnet. Aperture authenticates by Tailscale identity, so clients send a placeholder credential instead of a real key.

Prerequisites:

  • A running Tailscale Aperture gateway on your tailnet, reachable at <APERTURE_HOST>.
  • MagicDNS enabled so the gateway resolves by hostname.
  • At least one granted model for your Tailscale identity. Aperture denies access by default.
  • Claude Code and OpenCode installed on the client machine.

Time and difficulty: Intermediate. Allow about 15 minutes per tool once the gateway runs.

The One Constant Is the Host

Aperture is a reverse-proxy AI gateway. It sits between your clients and the upstream providers, holds the provider keys server-side, and routes each request to the serving backend. A client names a model, and Aperture maps the name to a provider, injects the credential, and forwards the call.

The single value you reuse across tools is the host, written here as <APERTURE_HOST>. On a tailnet with MagicDNS, this looks like a short name such as http://ai or a device name like https://aperture-host.tailnet-name.ts.net.

Aperture builds the upstream URL by appending the full incoming request path to the provider base URL. The client path selects the upstream API:

Client path the gateway receivesUpstream API it selects
/v1/messagesAnthropic Messages
/v1/chat/completionsOpenAI Chat Completions
/v1/responsesOpenAI Responses

This is why one host serves several agents while each still speaks its own protocol. Claude Code sends /v1/messages. OpenCode sends /v1/chat/completions. The host does not change. The path does.

This article covers the top layer of the series: the coding-agent clients. Below them sit Tailscale Aperture as the private edge, then the broker, then the backends. A public edge in front of Aperture, such as a LiteLLM deployment, is out of scope for this series.


How Aperture Handles Credentials

Aperture is keyless on the tailnet. It authenticates the caller by Tailscale identity through a WhoIs lookup on the client IP, so the client needs no real provider key. The gateway holds the upstream keys and injects them per request.

Some clients require a non-empty credential field even when the gateway ignores it. For those, send a placeholder such as - or not-needed. The placeholder satisfies the client’s own check. It is not sent upstream as a real key.

A reachable base URL is not a credential. Claude Code falls back to its claude.ai login unless an auth token is set, so the placeholder token is still required for a keyless gateway.


Claude Code

Claude Code speaks the Anthropic Messages format and nothing else. Point it at the Aperture host with ANTHROPIC_BASE_URL, and set a placeholder auth token so it does not fall back to a cloud login.

Set the host only. Claude Code appends /v1/messages itself, so do not add a /v1 suffix.

export ANTHROPIC_BASE_URL="<APERTURE_HOST>"
export ANTHROPIC_AUTH_TOKEN="-"
export ANTHROPIC_MODEL="<MODEL_ID>"
claude

For a persistent setup, use the env block in ~/.claude/settings.json. It survives across terminals and reaches background agents, unlike a shell export:

{
  "env": {
    "ANTHROPIC_BASE_URL": "<APERTURE_HOST>",
    "ANTHROPIC_AUTH_TOKEN": "-",
    "ANTHROPIC_MODEL": "<MODEL_ID>"
  }
}

Verification command:

curl "$ANTHROPIC_BASE_URL/v1/messages" \
  -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  -d '{"model":"<MODEL_ID>","max_tokens":16,"messages":[{"role":"user","content":"ping"}]}'

Status: untested. The Anthropic Messages path is verified against Claude Code and Aperture documentation, not against a live round trip. Confirm your gateway serves an anthropic_messages provider before relying on this. A plain OpenAI-compatible base URL returns an error for Claude Code, because it has no /v1/messages endpoint.

Caveats for this tool:

  • Do not append /v1 to ANTHROPIC_BASE_URL. Claude Code adds /v1/messages on its own.
  • Do not set both ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY. Use the auth token for a bearer gateway. The two together cause an auth conflict.
  • The settings file reaches background agents. A shell export does not.

Cursor

Cursor cannot reach a tailnet-private Aperture host. It routes every request through Cursor’s own servers and rejects localhost, 127.0.0.1, and private-LAN addresses. Aperture does not accept direct public internet access, so the two do not meet on a private tailnet.

If you expose an OpenAI-compatible edge on a public host, Cursor connects to it with bring-your-own-key settings. This public edge is a separate component from the private Aperture in this series.

Cursor Settings > Models > API Keys:
  OpenAI API Key:            YOUR_API_KEY
  Override OpenAI Base URL:  https://<PUBLIC_HOST>/v1
  + Add Custom Model:        <MODEL_ID>

Verification command: send a chat message in Cursor using the custom model, then confirm the request appears in your gateway logs.

Status: untested. This path needs a public endpoint, not the tailnet host, and was not run during research. Cursor chat uses the OpenAI Chat Completions API, and Agent mode uses the OpenAI Responses API, so the edge has to serve both.

Caveats for this tool:

  • Tab completion always uses Cursor’s own models. The override applies to chat models only.
  • Agent and Auto modes bypass the override in some builds.
  • All traffic passes through Cursor’s backend. There is no local-only session.
  • A claude- prefixed model id routes to Cursor’s own Anthropic path and skips the override. Use the exact allowlisted id.

OpenCode

OpenCode speaks OpenAI Chat Completions natively against the Aperture host. Define a provider, set its baseURL to the host plus /v1, and name the model. Put the configuration in opencode.json in your project, or in ~/.config/opencode/opencode.json.

{
  "$schema": "https://opencode.ai/config.json",
  "model": "aperture/<MODEL_ID>",
  "provider": {
    "aperture": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "Aperture (tailnet)",
      "options": { "baseURL": "<APERTURE_HOST>/v1" },
      "models": { "<MODEL_ID>": { "name": "<MODEL_DISPLAY_NAME>" } }
    }
  }
}

The npm package selects the wire format. Use @ai-sdk/openai-compatible for Chat Completions, @ai-sdk/anthropic for Anthropic Messages, or @ai-sdk/openai for OpenAI Responses.

Verification command:

opencode run --model aperture/<MODEL_ID> "reply with OK"

Status: untested. The provider shape is verified against the OpenCode provider documentation, not against a live gateway call. Match the baseURL path to the package you chose, and set the top-level model explicitly. A blank top-level model is reported to produce spurious 429 responses in community issue tracking.

Caveats for this tool:

  • Model ids must match the ids returned by GET /v1/models on the gateway.
  • Keyless needs no real key. Omit apiKey, or set a placeholder for a package with a non-empty-key check.
  • OPENCODE_CONFIG overrides the config file path when set.

Compatibility Table

ToolNative wire formatPath the gateway seesReachable at a private Aperture host
Claude CodeAnthropic Messages only/v1/messagesYes, a supported Aperture client
CursorOpenAI Chat Completions and OpenAI Responses/v1/chat/completions, /v1/responsesNo, needs a public endpoint through its cloud
OpenCodeOpenAI Chat, Responses, or Anthropic Messages, per npm packagepath of the chosen packageYes, a supported Aperture client

Keyless on the tailnet: Claude Code and OpenCode work with a placeholder credential. Cursor requires a real bring-your-own-key value sent to its backend.

For most terminal setups on a tailnet, Claude Code and OpenCode are the two agents a single Aperture host serves directly.


Troubleshooting

SymptomLikely causeFix
“Cannot connect using hostname”MagicDNS disabled or wrong URLEnable MagicDNS. Open <APERTURE_HOST> in a browser to confirm reachability
HTTP 403, or a model missing from /v1/modelsNo grant matches your identity. Aperture denies by defaultAdd a tailscale.com/cap/aperture grant whose model glob covers the model
HTTP 405A doubled /v1 in the base URL. Aperture appends the incoming pathStrip the trailing /v1 where the client already adds it
HTTP 404 on /v1/messages or /v1/responsesThe provider lacks the matching compatibility flagSet anthropic_messages or openai_responses on the provider block
HTTP 502 or an unresponsive modelThe broker or backend is unreachable from the gatewayConfirm the upstream is reachable from the gateway over the tailnet
Client refuses plain http://Some clients require HTTPS with a trusted cert for a fully-qualified hostnameUse the HTTPS FQDN with a trusted cert for those clients
“Connected” but requests fail with 401A missing placeholder credential, or a passthrough provider needs a real tokenAdd a placeholder key for OpenAI-wire clients. Forward a real token for subscription passthrough
Connection fails from a non-tailnet deviceAperture listens on Tailscale interfaces onlyUse an Aperture CLI bridge or ts-unplug, then point the client at http://localhost:<port>

Next Steps

Continue with Lesson 7: Fan Work Out to Bot Profiles .