Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Providers

A provider is the service that runs the model. efrd uses one provider for all new conversations. [model] provider in config.toml sets it:

ProviderModelsLogin
openai-subscription (the default)OpenAI models on your ChatGPT planefr login openai in a browser
openai-apiOpenAI models, billed to an OpenAI API keyefr login openai-api with a key
anthropic-apiAnthropic's Claude models, billed to an Anthropic API keyefr login anthropic with a key

This file tells how to log in with an API key, how to change the provider, which settings the Claude models have, and how the prompt cache works. config.md lists every key.

Log in with an API key

Make a key in the Claude Console (Anthropic) or in the dashboard of the OpenAI platform. Then give it to efr in one of three ways:

efr login anthropic                   # type the key at a prompt that does not show it
printenv ANTHROPIC_API_KEY | efr login anthropic
efr login anthropic --from-env        # read ANTHROPIC_API_KEY of this shell

efr login openai-api works the same, with OPENAI_API_KEY for --from-env. efr login anthropic-api is the same as efr login anthropic.

  • efr never takes a key as an argument. Other users can read the arguments of a process, and the shell keeps them in its history.
  • efrd checks the key with one request that runs no model. For Anthropic, this is GET /v1/models?limit=1. When the API refuses the key, efr shows the message of the API and keeps nothing.
  • After the check, efrd keeps the key in secrets/ of its data root (~/.local/share/efr/secrets/ by default), in a file that only you can read. The permission rules of efr deny this directory to the tools of the agent.
  • --no-check keeps the key without the check, for a computer that is offline.
  • efr status shows each provider with its login. A key shows only as its start and its last four characters, such as sk-ant-...a1b2. (active) marks the provider of new conversations. When the provider refused the key (a 401 on a model call or on the fetch of the model list), the line says when, such as key sk-ant-...a1b2 refused at 2026-10-09T12:03:00Z (5m 0s ago). The mark goes away when a request of the same kind with the key works again, or after a new login. The model calls decide: after a model call that worked, a refused fetch of the model list does not show.
  • efrd keeps these refusals in memory only. After a restart of efrd, and until the first request with the key gets an answer, the line says key sk-ant-...a1b2 not checked since efrd started. A model call, a fetch of the model list or a login with the check checks the key.
  • efr logout anthropic deletes the key from efrd. The key stays valid at Anthropic: revoke it in the Claude Console. When the provider is the one of new conversations, efr warns that it stays in [model] provider, so its turns fail until you log in again.
  • OpenAI bills an openai-api key per token, apart from a ChatGPT plan. efr says so after efr login openai-api and after efr config set model.provider openai-api.
  • A key can expire, such as a Console key with an expiry date. Then every request fails as unauthorized. Make a new key and log in again.

Keep the key out of the environment of efrd

Do not put an API key in the environment of the efrd service: not in ~/.config/environment.d, not with systemctl --user set-environment and not in the unit file. efrd does not read ANTHROPIC_API_KEY or OPENAI_API_KEY from its environment. But efrd gives its whole environment to the hidden shells. In the manual and cautious modes, a command of the agent can then read the key with env. Only the auto sandbox removes names such as *API_KEY*. Use efr login.

Change the provider

A login does not change the provider of new conversations. After the login, efr tells you what to do when the provider is not the active one:

efr config set model.provider anthropic-api
systemctl --user restart efrd

[model] provider needs a restart of efrd. efr models then lists the models of the new provider, and its last line names the provider, such as models: anthropic-api, from the backend, fetched 5m ago. A terminal that chose a model with ,model keeps that choice: choose a model of the new provider, or clear the choice with ,model default.

Also change [model] name when it names a model of the other company, such as gpt-5.5 with anthropic-api, or remove it. Else every prompt that names no model of its own fails with this cause and fix. efr config check warns about such a name, and efrd logs a warning at start and after a reload. A model id in the list of models of the new provider ([anthropic] models or [openai] models) is not of the other company.

Claude models

efr has no built-in list of Claude models. The models, their context windows, their output limits and their efforts come from the model list of the API (GET /v1/models, only the models that are active).

  • efrd gets the list at start, after each login and then every hour. A failed request tries again after 15 s, 30 s, 1 min and 2 min, then every 5 min.
  • efrd keeps the list in anthropic_model_catalog.json in its state root (~/.local/state/efr/ by default). A start without network uses that file.
  • Before the first list (the first start, or a start before the network is up), a prompt waits for one request of the list, for at most 60 s. efr models then says no list yet; efrd fetches it before the next prompt.
  • The default model is claude-opus-5-5 when the API lists it. [model] name sets another model, such as claude-sonnet-5-5. ,model sets one for a terminal.
  • The default effort is medium, as in Claude Code. [model] effort and ,effort set another one. efr sends the effort to each model that lists it. A model that takes no effort gets none.
  • The context window is the window of the list: 1M tokens on the current models. efr compacts at [compaction] auto_at of the window (76% by default).
  • efr sends the signed thinking of each answer back unchanged, so the model keeps its reasoning through a tool loop. While the model thinks, the status row shows thinking and the title of the summary of its thinking.
  • Claude models change files with the edit tool. OpenAI models use apply_patch.
  • Without a key and without a list, a prompt fails as unauthorized and says log in with efr login anthropic. When the request of the list failed, the prompt fails with efr could not fetch Claude's model list and the cause, such as the message of the API for a refused key or a request without an answer. A model whose output limit the config gives ([anthropic] models or [model] max_output_tokens) runs without a list.

The [anthropic] keys

KeyWhat it does
modelsAdds a model that the API does not list yet, or lowers the window or the output limit of a listed model. An entry is a model id or a table { id, context_window, max_output_tokens }. Applies to the next turn.
cache_ttlHow long the prompt cache keeps what a request writes: auto (the default), 5m or 1h. See "The prompt cache" below. Needs a restart.
workspace_idThe workspace of your key, sent as the anthropic-workspace-id header of every request and of the login check. Needs a restart.
base_urlReplaces https://api.anthropic.com/v1, such as for a proxy. Needs a restart.

A smaller window costs less

A large window lets a conversation grow, and each call sends the whole conversation. Cached input is cheap, but it is not free. To compact sooner, lower the window:

[anthropic]
models = [{ id = "claude-opus-5-5", context_window = 272000 }]

Claude Haiku 5.5 has a price tier: when a request is longer than 100k tokens, the whole request costs 5 times as much. We recommend a window of 100k for Haiku, so that efr compacts before the tier:

[anthropic]
models = [{ id = "claude-haiku-5-5", context_window = 100000 }]

efr config check notes a window above the largest window of its model. efrd then uses the largest window.

The workspace id

A key that belongs to one workspace needs no workspace id. A key that is not scoped to one workspace, such as a key of a personal or a service account, needs one. Without it, the API refuses every request with a 400 that names anthropic-workspace-id, and the login check shows that message. You can:

  • make a new key in the Console that is scoped to one workspace (the simpler way); or
  • set the id of the workspace, then log in again:
efr config set anthropic.workspace_id wrkspc_01AbCd
systemctl --user restart efrd

The workspace id is not a secret, so it can be in a dotfiles repository.

The reviewer

In auto, a reviewer model decides most actions that leave the sandbox (docs/sandbox.md, "Exits"). efr asks the models in this order:

  1. classifier.model when it is set, else the small model of the turn's provider: gpt-6-luna for openai-subscription and openai-api, claude-haiku-5-5 for anthropic-api.
  2. The turn's model at the effort classifier.effort (default low), unless classifier.fallback_to_turn_model = false.
  3. You.

efr goes to the next step when a model is not in your model list, refuses, takes more than classifier.timeout_secs (default 20) or gives an answer that does not fit. An answer with an empty reason or a placeholder reason, such as x, does not fit. The first model then gets the same request once more before efr goes to the next step. A model that is missing or refuses is skipped for the rest of the conversation. A provider that refuses the login fails for every model, so the question then goes to you at once. efr history --verbose shows the model, the step and the skipped models of each review, and asked again for a real reason when the first model got its request a second time.

Each review is one more request to your provider. With an API key, you pay for it per token; with a subscription, it counts against the limits of your plan. A review sends only the facts of one action and your messages, with no tools and no history, so it is small. Within a turn, the same action gets the same verdict without a second request. classifier.enabled = false sends no review and asks you about every action that leaves the sandbox.

A review sends no prompt cache key, so an OpenAI review goes over HTTP and never touches the WebSocket of the turn. A Claude review marks only its fixed prompt for the cache, for five minutes.

The prompt cache

The provider keeps the start of each request in a cache. The next request that starts with the same text reads it for a small part of the price. efr keeps the history of a conversation append-only, so each request starts with the request before it and its answer.

The end-of-turn line shows the part of the turn's input that came from the cache, such as done in 42s, ctx 43% (89k/207k), 1.1k out, cache 91%. A write to the cache counts as a miss. The line shows it only when the turn's input is 2048 tokens or more: below 512 tokens (Claude) or 1024 tokens (OpenAI), no cache entry forms.

OpenAI caches each request without any setting. Claude caches only where the request puts a marker, and each marker keeps its entry for five minutes or for one hour.

cache_ttl

A write for five minutes costs 1.25 times the input price. A write for one hour costs 2 times. A read costs 0.1 times or less. Each read starts the time to live again. A pause longer than the time to live loses the entry, and the next request writes it again.

ValueWhat it does
auto (default)efr's choice. Today it is the same as 1h: an entry for one hour cannot build on an entry for five minutes, so one hour for the conversation and five minutes for each tool loop would write each tool loop twice. A pause up to one hour, between two prompts or while efr waits for your approval, loses nothing.
5mEverything for five minutes. This costs less when you send the next prompt within five minutes. A longer pause writes the whole conversation again.
1hEverything for one hour. Each write costs more, and a pause up to one hour loses nothing.

A summary call of a compaction uses the same time as the other calls, so it reads the cache of the conversation.

What makes the cache miss

EventClaudeOpenAI
A change of the model (,model)The whole cache: each model has its ownThe whole cache
A change of the effort (,effort)The conversation; the system prompt and the tools stayCan miss
A new [model] system_promptThe system prompt and the conversationThe whole cache
A compactionThe conversation; the system prompt and the tools stayThe whole cache
A pause longer than the time to liveThe whole cacheThe whole cache (OpenAI keeps an entry for 30 minutes or more)
A restart of efrdNothingNothing

efr history --verbose shows the tokens of each turn on one muted line, such as 18.2k in, 1.1k out, cache 91%, wrote 1.2k (1h): the input, the output, the part of the input that came from the cache, and the cache writes, with (1h) when they were for one hour ((800 1h) when only a part was). An interrupted or failed turn that reported its tokens shows the line too.

Measure the time to live

With log = "info,efr_conversation::usage=debug" in config.toml (the key applies at once, with no restart), the log of efrd shows one line for each model call of a turn that reported its tokens, such as usage gap_ms=95000 input=18200 output=1100 read=16600 write=1200 write_1h=1200. The line holds numbers only, never the text of a prompt or an answer:

  • gap_ms: the time since the start of the conversation's call before, or none for the first call after a start of efrd.
  • input and output: the tokens of the call; read: the part of the input that came from the cache; write: the cache writes; write_1h: the part of the writes for one hour.

Read the lines with journalctl --user -u efrd --since -7d | grep 'usage gap_ms=', and set log back when you have enough of them. With log = "info,efr_=debug", the log also shows:

  • gap_ms on every line of a call: the time since the start of the conversation's call before. The first call after a start of efrd has none.
  • For Claude, one line with the markers of the request, such as cache_ttl=auto markers=S1h,A1h,P1h,T1h: S is the system prompt and the tools, A an earlier point in the conversation (the start of a turn, or about every 20,000 tokens of a tool loop), P the end of the call before and T the end of this request.

The turn_completed event of each turn holds its token counts: the cached input (cached_input_tokens), the cache writes (cache_write_tokens) and the writes for one hour (cache_write_1h_tokens). Together, these show which cache_ttl costs less for the way you work.