Model providers and custom endpoints¶
Which model an agent runs, and against which endpoint, is a ledger scalar per agent —
model, skill_model, provider, and an endpoint: block. agentctl agents set <name>
<key> <value> records it; apply renders it.
Two endpoint shapes exist and they are mutually exclusive — setting one clears the other:
| claude | pi | |
|---|---|---|
endpoint_base_url |
rendered as ANTHROPIC_BASE_URL |
registered as a base-URL override on a named provider |
foundry_resource / foundry_base_url |
Azure AI Foundry | ignored — pi has no foundry provider |
Two refusals worth knowing, because both are silent misconfigurations otherwise:
- A pi agent with
endpoint.foundry_*set is warned that the field is ignored and the agent stays on its provider's own endpoint. Useendpoint.base_urlfor a pi gateway. - A pi agent with
endpoint.base_urlbut nomodel.provideris warned that the URL is ignored: a pi base URL overrides a named provider, so with none pinned there is nothing to override. Fix withagentctl agents set <name> provider <name>.
For claude, --provider selects anthropic (default) or foundry; choosing foundry
means declaring the endpoint with agents set <name> foundry_resource <resource> or
foundry_base_url <url>.
Where the credential lives¶
The endpoint: block is non-secret by construction — the ledger is a plaintext git
repo, so no key field exists there and none ever will. The matching credential is a
per-agent secret in the sops store, and which var it goes under follows the mode:
| mode | store the key as (either one) |
|---|---|
claude, endpoint_base_url |
ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN |
claude, foundry_resource / foundry_base_url |
ANTHROPIC_FOUNDRY_API_KEY or ANTHROPIC_FOUNDRY_AUTH_TOKEN (an Entra bearer token) |
pi, endpoint_base_url |
the pinned provider's own var — see below |
A pi base URL is an override of a provider, so the credential is unchanged from a pi
agent with no custom endpoint: anthropic → ANTHROPIC_API_KEY, openai →
OPENAI_API_KEY, google/gemini → GEMINI_API_KEY, groq → GROQ_API_KEY,
cerebras → CEREBRAS_API_KEY, xai → XAI_API_KEY, openrouter →
OPENROUTER_API_KEY. Any other provider name maps to <NAME>_API_KEY, uppercased.
sudo agentctl secret set ANTHROPIC_FOUNDRY_API_KEY --agent <name> --value "$KEY"
printf %s "$KEY" | sudo agentctl secret set ANTHROPIC_FOUNDRY_API_KEY --agent <name>
The second form keeps the key out of argv, which is world-readable on a multi-agent host.
apply renders both halves into two files, and the split matters when you go looking:
| file | holds | read by |
|---|---|---|
~/.config/agentctl/agent.env |
CLAUDE_MODEL, CLAUDE_SKILL_MODEL, ANTHROPIC_BASE_URL, ANTHROPIC_FOUNDRY_* |
start-agent.sh and cron-run.sh, both under set -a so the vars reach the claude child |
/etc/agentctl/env/agent-<name>.env |
the sops-rendered secrets | the unit's EnvironmentFile= |
~/.config/agentctl/pi-agent.env |
PI_PROVIDER, PI_MODEL, PI_BASE_URL, PI_SKILL_MODEL |
the pi bridge |
Nothing in the endpoint block is written by hand. apply reconciles rather than
upserts: clearing endpoint_base_url removes ANTHROPIC_BASE_URL on the next apply, so a
decommissioned gateway actually stops taking traffic instead of living on in a stale env
file.
Azure AI Foundry, end to end¶
Foundry is claude-only. Three commands on an existing agent:
sudo agentctl secret set ANTHROPIC_FOUNDRY_API_KEY --agent ada --value "$FOUNDRY_KEY"
sudo agentctl agents set ada foundry_resource my-foundry-resource
sudo agentctl apply ada
foundry_base_url "$URL" is the alternative spelling — the full endpoint URL Azure shows
for the resource, instead of the resource name. The two are alternatives, not a pair.
At add time the key can ride along, and only the key:
sudo agentctl agents add ada --bot-token "$TOKEN" --api-key "$FOUNDRY_KEY" --provider foundry
--provider foundry chooses which var the key is stored under and nothing else. It
declares no endpoint, so agents add prints the agents set command still owed — a
Foundry key with no endpoint starts an agent that fails every turn. Any other
--provider value on a claude agent is refused with a warning and the key is stored as
ANTHROPIC_API_KEY.
Model ids pass through untranslated. agentctl hands the model scalar to
claude --model verbatim and performs no deployment-name mapping; how the Foundry client
resolves that id to a deployment is Claude Code's business, not agentctl's. If a
deployment is named differently from the model id, the model scalar is where that name
goes.
Note also that --provider the flag and provider the ledger scalar are different
things. The flag is consumed at add time to pick a key var. The scalar is a pi concept:
set on a claude agent to anything but anthropic, agents validate warns that it is
ignored, because claude speaks only Anthropic.
A custom base URL¶
claude, against a self-hosted or corporate Anthropic-compatible gateway:
sudo agentctl secret set ANTHROPIC_API_KEY --agent ada --value "$GATEWAY_KEY"
sudo agentctl agents set ada endpoint_base_url https://gateway.corp.example/anthropic
sudo agentctl apply ada
pi, where the provider must be pinned first — the base URL overrides a named provider:
sudo agentctl agents set bo provider openai
sudo agentctl agents set bo model <model-id>
sudo agentctl secret set OPENAI_API_KEY --agent bo --value "$KEY"
sudo agentctl agents set bo endpoint_base_url https://gateway.corp.example/v1
sudo agentctl apply bo
What PI_BASE_URL does is narrower than the claude equivalent and worth being precise
about: it layers a base-URL override over the built-in provider. The model catalog, the
per-model costs, the api implementation and the provider's own env-var auth all stay as pi
ships them; only the URL moves. It does not declare a new provider, which is why one has
to be named.
Switching modes goes through an explicit clear — agents set refuses a second endpoint
key while another is declared, since an agent carrying both hands the runtime two
endpoints and lets it pick:
sudo agentctl agents set ada endpoint_base_url ""
sudo agentctl agents set ada foundry_resource my-foundry-resource
agents set also refuses a base URL with no http:// or https:// scheme. A bare host
reaches the runtime as an unusable URL and fails at the first turn.
Which model runs where¶
The scalars do not override each other — they name different lanes, each resolved independently:
| lane | claude | pi |
|---|---|---|
| main session | model → CLAUDE_MODEL, default sonnet |
model → PI_MODEL, honoured only with provider also set; otherwise the bridge auto-discovers |
| turn-review skilling hook | skill_model → CLAUDE_SKILL_MODEL, default claude-haiku-4-5 |
skill_model → PI_SKILL_MODEL; empty falls back to PI_MODEL |
| subagents | each subagent's own model: frontmatter — coding and deep-work pin opus |
— |
| crons | by skill, CLAUDE_CRON_MODEL overriding per unit — see Crons |
the headless pi runner, on PI_PROVIDER + PI_MODEL |
The two claude defaults are back-filled into the ledger on apply rather than left as
fallbacks in the launcher, so agents list shows what an agent actually runs on and a
changed default reaches agents that already exist. pi gets neither default: a
claude-flavoured model id is meaningless on a Groq or OpenRouter provider.
The main-session default is sonnet and the Opus subagents are the other half of that
decision — the ordinary turn is cheap, and work that needs Opus is routed there explicitly
by coding or automatically by deep-work.
model and skill_model are ledger-only until an apply. agents set writes and commits
the scalar, then names the apply you owe it; apply re-renders the env file and, because the
ledger entry and the secret digest are both in the agent's fingerprint, bounces the agent
so the new value is in the process that reads it.
What a wrong endpoint looks like¶
Neither runtime treats a bad endpoint as a startup failure. pi's provider registration never throws — an unknown provider id composes to an empty model list — and claude's launcher only exports a var. So the agent starts clean and every turn fails on auth or connection, which from the cockpit is indistinguishable from the model being down. That is the reason to read the earlier gates rather than wait for a turn:
| gate | what it says |
|---|---|
agents set |
refuses the second endpoint key (the endpoint modes are alternatives; clear it first), and a base URL with no scheme |
agents validate <name> |
pi: provider-key when the store holds no key for the pinned provider, provider when a model is pinned without one. claude: provider when the scalar is anything but anthropic |
apply |
<name> declares a custom endpoint but the store holds neither ANTHROPIC_FOUNDRY_API_KEY nor ANTHROPIC_FOUNDRY_AUTH_TOKEN — every turn will fail auth, plus the two pi refusals above |
| runtime | agentctl agents logs <name> — the unit journal, via agentd |
The apply-time credential check is deliberately narrow: it reads the secret store only for an agent that declares an endpoint, so it costs a sops decrypt on the handful of agents that route elsewhere and nothing on the rest.
The failure mode with no error at all is the one to watch for: falling back to the default
Anthropic endpoint is not an error, it just bills the wrong account. agentctl agents logs
shows a turn that failed; it does not show a turn that succeeded against the wrong
endpoint. Confirm the vars the child actually received rather than the ones the ledger
declares.