Control Tower v0.2.1

Connect your agents

An agent needs two things: Control Tower's address and its own key (create one per agent under Keys — see Keys, budgets and limits). The key's Connect panel shows every snippet below with your address and key filled in, and turns green on the agent's first request.

Step-by-step guides, with screenshots: Claude Code (CLI) · Claude Desktop (GUI) · Codex in the ChatGPT desktop app · Codex (CLI).

Keep the model names you use today. Control Tower resolves them to a connected provider, adding models on first use.

Client speaksPoint it atKey goes in
OpenAI API (chat, embeddings, Responses)http://<host>:4000/v1 (or the bare origin)Authorization: Bearer — OPENAI_API_KEY
Anthropic Messages APIhttp://<host>:4000x-api-key — ANTHROPIC_API_KEY, or Authorization: Bearer — ANTHROPIC_AUTH_TOKEN
MCP (Streamable HTTP)http://<host>:4000/mcpAuthorization: Bearer
Plain HTTP to a registered APIhttp://<host>:4000/http/<slug>/…x-ct-key

Azure's api-key header is accepted too, so clients configured for Azure OpenAI work unchanged.

OpenAI SDKs (Python, Node) and most frameworks

No code changes — the SDKs read these variables:

export OPENAI_BASE_URL=http://localhost:4000/v1
export OPENAI_API_KEY=ct_sk_…

Or in code:

from openai import OpenAI
client = OpenAI(base_url="http://localhost:4000/v1", api_key="ct_sk_…")
client.chat.completions.create(model="gpt-4.1-mini", messages=[{"role": "user", "content": "hello"}])
import OpenAI from 'openai';
const client = new OpenAI({ baseURL: 'http://localhost:4000/v1', apiKey: 'ct_sk_…' });

The same OpenAI client can call Claude or Gemini models: requests are translated for Anthropic, Gemini, Vertex AI and Bedrock.

OpenAI Agents SDK and Codex (Responses API)

Both use OpenAI's Responses API. /v1/responses goes through the same pipeline — keys, limits, gates, approvals, inspection and cost. It is forwarded as it is to OpenAI, Azure OpenAI and OpenAI-compatible providers, and translated through Chat Completions for Claude, Gemini, Bedrock and Vertex AI models, so both can run on any model. The Agents SDK needs only the environment variables above:

export OPENAI_BASE_URL=http://localhost:4000/v1
export OPENAI_API_KEY=ct_sk_…
python my_agent.py

Codex is set up in ~/.codex/config.toml: see Codex (CLI) and Codex in the ChatGPT desktop app.

from agents import Agent, Runner
agent = Agent(name="support", instructions="Be brief.", model="gpt-4.1-mini")
print(Runner.run_sync(agent, "Summarise ticket 8812").final_output)

Translated Responses calls keep text and image input, function tools and their results, JSON-schema output and usage. Reasoning items, OpenAI's built-in tools (web and file search, computer use) and previous_response_id need a provider with the Responses API.

Claude Code and the Anthropic SDKs

Step by step: Claude Code (CLI) · Claude Desktop (GUI).

The connect panel's Claude Code tab

export ANTHROPIC_BASE_URL=http://localhost:4000
export ANTHROPIC_AUTH_TOKEN=ct_sk_…      # Claude Code
claude
export ANTHROPIC_BASE_URL=http://localhost:4000
export ANTHROPIC_API_KEY=ct_sk_…         # Anthropic SDKs

Requests from Claude Code to an Anthropic provider are forwarded as they are, so prompt caching, extended thinking and tool use keep working; for Claude on Bedrock or Vertex AI they are translated. /v1/messages/count_tokens is forwarded to Anthropic for exact counts. An Anthropic-format request for a GPT or Gemini model is translated.

LangChain, LlamaIndex and other frameworks

Anything that takes an OpenAI-compatible base URL works:

from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4.1-mini", base_url="http://localhost:4000/v1", api_key="ct_sk_…")
import { ChatOpenAI } from '@langchain/openai';
const llm = new ChatOpenAI({ model: 'gpt-4.1-mini', apiKey: 'ct_sk_…', configuration: { baseURL: 'http://localhost:4000/v1' } });

MCP clients

Register tool servers under MCP servers (see MCP tool servers), then connect clients to one address with the agent's key. Tools from every server are listed as server__tool; /mcp/<slug> exposes a single server with its original tool names. Tools the key may not use are not listed at all.

The connect panel's MCP tab

Claude Code

claude mcp add --transport http controltower http://localhost:4000/mcp --header "Authorization: Bearer ct_sk_…"

This saves the server for the current project only (local scope). Add --scope user to use it in every project.

Cursor (~/.cursor/mcp.json) and other clients that take a URL and headers:

{
  "mcpServers": {
    "controltower": {
      "url": "http://localhost:4000/mcp",
      "headers": { "Authorization": "Bearer ct_sk_…" }
    }
  }
}

OpenAI Agents SDK

from agents.mcp import MCPServerStreamableHttp
tools = MCPServerStreamableHttp(params={"url": "http://localhost:4000/mcp", "headers": {"Authorization": "Bearer ct_sk_…"}})

A tool call held for approval comes back as a tool result with isError: true explaining that a human must approve and how to retry, so the model can tell the user instead of failing silently.

Plain HTTP APIs

For REST APIs without an MCP server, register them under HTTP APIs and call /http/<slug> with the agent's key; Control Tower adds the API's stored credentials. See HTTP APIs.

curl http://localhost:4000/http/statuspage/api/v1/components -H "x-ct-key: ct_sk_…"

Test with curl

curl http://localhost:4000/v1/chat/completions \
  -H "Authorization: Bearer ct_sk_…" -H "Content-Type: application/json" \
  -d '{"model": "gpt-4.1-mini", "messages": [{"role": "user", "content": "hello"}]}'

Every model response carries x-ct-flight-id: search for it under Flights to see what happened to the request.

When a request is refused

Errors use the envelope of the API the client speaks (OpenAI or Anthropic), with a code an agent can act on:

StatuscodeMeaning
401invalid_api_key, key_disabled, key_expiredMissing, unknown, blocked or expired key
403model_not_allowedThe key's allowed models don't include it
403policy_deniedA gate blocks this path
403approval_requiredHeld for a human; retry with x-ct-approval: <ticket> after approval
400content_blockedAn inspect gate found something it blocks
404model_not_foundNo connected provider serves that model name
429rate_limit_exceeded, too_many_parallel_requests, budget_exceededThe key's rate limit, parallel-request limit or budget
429provider_rate_limitedThe provider rate-limited the call, after any fallbacks
400provider_bad_requestThe provider rejected the request as invalid (not retried elsewhere)
502 / 504provider_error, provider_auth_error, provider_timeout, …The provider failed, after any fallbacks

Every code, for model calls, MCP, HTTP APIs and A2A, is in Troubleshooting.