Before Agent Profiles, the picker meant “use this LLM next”
The visible control was an LLM Profile picker. Before conversation start it materialized a durable default; during a conversation the same control switched only that conversation. Single-user agent-servers owned one installation-wide settings namespace. Cloud split ownership across the organization and the calling member—with a permission flaw for ordinary teammates.
1The answer in one screen
Installation-owned on an agent-server; organization-owned on Cloud.
Activation copied the selected LLM into effective agent settings.
The live runtime changed; the future-conversation default did not.
Org marker, member settings, and admin permission did not line up cleanly.
Evidence: the historical Canvas mutation explicitly distinguishes global activation from conversation switching use-switch-llm-profile.ts:10–48; the composer rendered SwitchProfileButton for OpenHands and the ACP model control otherwise chat-input-actions.tsx:398–414.
Flow sources: single-user activation writes both settings fields profiles_router.py:290–350; Cloud activation performs the org and member writes atomically org_profiles.py:265–351; running-conversation switching is routed separately agent-server-conversation-service.api.ts:679–743.
2The old mental model: catalog, pointer, materialized settings, conversation
“LLM Profile” was not one state. It was a named catalog entry plus an active name, a flattened LLM configuration used at conversation start, and—during a conversation—a runtime choice.
| State | Single-user agent-server | Cloud before Agent Profiles | What it controlled |
|---|---|---|---|
| Profile definitions | installation LLMProfileStore | organization org.llm_profiles | Reusable model, endpoint, parameters, and credential material. |
| Active profile name | installation persisted settings | organization profiles.active | What the UI called active; not itself the runtime configuration. |
| Effective LLM settings | installation agent_settings.llm | member agent_settings_diff.llm merged over org settings | The LLM configuration materialized when the next conversation started. |
| Running LLM | conversation | conversation | The model used by one existing conversation after a live switch. |
Ownership evidence: local persisted settings define agent_settings and active_profile persistence/models.py:113–150; Cloud profile listing reads the org collection and its active name org_profiles.py:168–182; Cloud settings merge the calling member’s diff over org settings saas_settings_store.py:144–220.
3Single-user backends: one settings namespace, wherever it ran
Local process, all-in-one Docker, or a personal VM changed transport—not state ownership. Canvas classified all agent-server protocol backends as the non-Cloud path; the server persisted one profile catalog and one settings document for that installation.
Transport grounding: the historical service branches only on backend.kind === "cloud"; the non-Cloud branch uses ProfilesClient and ConversationClient agent-server-conversation-service.api.ts:695–743. Storage grounding: the agent-server lists profiles from LLMProfileStore and reads active_profile from its settings store profiles_router.py:125–144.
/{name}/activate endpoint.LLMProfileStore.agent_settings.llm; its name becomes active_profile.agent_settings.llm. Docker and remote-VM deployments behaved the same when they exposed that same single-user agent-server API.4Cloud: org-owned definitions, member-effective conversation starts
Cloud deliberately reused the product behavior, but not the same ownership model. The organization stored profile definitions and the visible active name. Activation then copied the chosen LLM into only the calling member’s effective settings and encrypted key fields.
POST /api/organizations/{orgId}/profiles/{name}/activate.profiles.active = name changes the organization-owned collection.member.agent_settings_diff["llm"].user.agent_settings.llm.saas_settings_store.py:164–214 · live_status_app_conversation_service.py:1171–1208
EDIT_ORG_SETTINGS because it changed org state. Personal-org owners and team admins could persist a choice. Ordinary members could list profiles but their blank-composer activation failed authorization—even though they could still switch an already-running conversation.Permission evidence: listing requires VIEW_ORG_SETTINGS org_profiles.py:168–182, while activation requires EDIT_ORG_SETTINGS org_profiles.py:265–283. The in-conversation route resolves profiles from user settings without mutating org activation state app_conversation_router.py:620–670.
A subtler inconsistency followed. One admin could change the org-wide profiles.active name while only that admin’s member settings were updated. Another member could therefore observe the new active label while their next conversation still used an older materialized LLM.
This is a direct consequence of the two scoped writes in one activation: org-wide profiles.active at org_profiles.py:285–297, versus the calling member selected by org_id + user_id and updated at org_profiles.py:302–346.
5Inside a running conversation, persistence stopped
During a conversation, the dropdown stopped meaning “future default.” Canvas passed a conversation id, and each backend switched that runtime directly.
| Backend | Switch mechanism | State changed | State not changed |
|---|---|---|---|
| Agent-server | Resolve the named profile, then call ConversationClient.switchLLM. | conversation running LLM | Installation active_profile and agent_settings.llm. |
| Cloud | POST /app-conversations/{id}/switch_profile; app server resolves the profile and calls sandbox switch_llm. | conversation runtime and persisted conversation llm_model | Org profiles.active and member future settings. |
Agent-server switch: agent-server-conversation-service.api.ts:725–743. Cloud switch resolution and sandbox call: app_conversation_router.py:620–670 and :689–734. Cloud persists only the conversation model after a successful switch at :600–617.
6Backend and tenancy matrix
| Shape | Profile owner | Blank-composer choice persisted? | Running switch? | Important boundary |
|---|---|---|---|---|
| Standalone SDK code | Caller-selected LLMProfileStore path | Not a Canvas behavior; SDK supplied reusable definitions, while activation lived in the agent-server API. | SDK runtime switching existed when invoked by the caller. | No product tenancy or composer on its own. |
| Canvas + local process | One installation | yes into persisted agent settings | yes | One effective user/settings namespace. |
| Canvas + Docker | One installation | yes same agent-server contract | yes | Deployment packaging did not add tenancy. |
| Canvas + personal VM server | One installation | yes same non-Cloud path | yes | Remote location did not make it Cloud. |
| Cloud personal org owner | Org definitions + owner member settings | yes | yes | Owner had org-edit permission. |
| Cloud team admin | Org definitions + admin member settings | yes, but org marker changed for everyone | yes | Only the acting admin’s effective LLM was copied. |
| Cloud ordinary member | Org definitions + member settings | no activation authorization failed | yes | Could view profiles, not edit org state. |
| ACP conversation | ACP provider/session model semantics | Not an LLM Profile choice. | Separate ACP model operation. | Canvas rendered the model picker instead of SwitchProfileButton. |
SDK definition storage: llm_profile_store.py; direct SDK profile switching loads the profile and delegates to switch_llm local_conversation.py:953–1021. Local/Docker/remote equivalence follows Canvas’s single non-Cloud agent-server branch agent-server-conversation-service.api.ts:695–743. Cloud permissions and side effects are explicit in org_profiles.py:265–346. ACP/OpenHands picker split: chat-input-actions.tsx:398–414.
7The transition: a brief Cloud LLM era, then Agent Profiles
llm_profile_ref. PR #1571 · merge commit 96b65f89