Historical reconstruction · OpenHands profiles

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.

Source snapshots: OpenHands/agent-canvas @ 0c979071 · OpenHands/OpenHands @ b8d0c618 · OpenHands/software-agent-sdk @ ff03816d · verified June 2026

1The answer in one screen

Verified conclusion. On the blank composer, choosing an LLM Profile changed what the next conversation would use. Once a conversation existed, choosing another profile changed only that conversation. The visible name “active profile” hid different storage owners on local and Cloud backends.
Profile catalogsaved

Installation-owned on an agent-server; organization-owned on Cloud.

Before conversation startdurable

Activation copied the selected LLM into effective agent settings.

During conversationscoped

The live runtime changed; the future-conversation default did not.

Cloud caveathybrid

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.

Historical LLM Profile behavior before Agent Profiles Two lanes show single-user and Cloud activation before conversation start. Both materialize LLM settings used by the next conversation. A separate bottom lane shows that selecting during a conversation changes only that conversation. BEFORE CONVERSATION START — THE CHOICE PERSISTS single-user Choose “Claude”composer picker Persist installation settingsagent_settings.llm + active_profile Next conversation uses Claudesettings copied into start request Cloud Choose “Claude”org profile picker Two writes, one transactionorg profiles.active = “Claude”caller member.agent_settings_diff.llm Next conversation uses Claudemember-effective settings at conversation start DURING CONVERSATION — THE CHOICE STAYS HERE ONLY Running on GPTconversation A choose Claude Runtime switches to Claude/switch_profile or switchLLM Future default unchangedconversation B may differ
One control, two temporal meanings: durable before conversation start; conversation-scoped during a conversation.

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.

StateSingle-user agent-serverCloud before Agent ProfilesWhat it controlled
Profile definitionsinstallation LLMProfileStoreorganization org.llm_profilesReusable model, endpoint, parameters, and credential material.
Active profile nameinstallation persisted settingsorganization profiles.activeWhat the UI called active; not itself the runtime configuration.
Effective LLM settingsinstallation agent_settings.llmmember agent_settings_diff.llm merged over org settingsThe LLM configuration materialized when the next conversation started.
Running LLMconversationconversationThe 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.

1
Canvas calls profile activation.With no conversation id, the service posts to the agent-server’s /{name}/activate endpoint.

agent-server-conversation-service.api.ts:718–723

2
The server loads the named profile.Its complete LLM configuration comes from LLMProfileStore.

profiles_router.py:306–318

3
Activation materializes two fields.The profile’s LLM becomes agent_settings.llm; its name becomes active_profile.

profiles_router.py:320–335

4
The next conversation start reads those settings.Canvas fetches settings, encrypts the start payload, and creates the conversation with that materialized LLM.

agent-server-conversation-service.api.ts:381–408

Practical result. On a personal installation, “last selected starts next conversation” emerged naturally because the selected profile overwrote the single effective 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.

1
Canvas calls the organization endpoint.POST /api/organizations/{orgId}/profiles/{name}/activate.

profiles-service.api.ts:116–139

2
The org marker advances.profiles.active = name changes the organization-owned collection.

org_profiles.py:285–297

3
The calling member gets the effective LLM.The backend masks the key in JSON, stores the effective key in member-encrypted columns, and writes member.agent_settings_diff["llm"].

org_profiles.py:298–346

4
Conversation start uses member-effective settings.The settings store overlays the member diff on org settings and resolves the effective API key; conversation construction then starts from user.agent_settings.llm.

saas_settings_store.py:164–214 · live_status_app_conversation_service.py:1171–1208

The team-org flaw. Activation required 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.

BackendSwitch mechanismState changedState not changed
Agent-serverResolve the named profile, then call ConversationClient.switchLLM.conversation running LLMInstallation active_profile and agent_settings.llm.
CloudPOST /app-conversations/{id}/switch_profile; app server resolves the profile and calls sandbox switch_llm.conversation runtime and persisted conversation llm_modelOrg 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.

The historical product contract. “Choose on home” meant activate for later conversations. “Choose in chat” meant switch this chat. That temporal inconsistency was already poor UX; Cloud’s org/member ownership made it worse for teams.

6Backend and tenancy matrix

ShapeProfile ownerBlank-composer choice persisted?Running switch?Important boundary
Standalone SDK codeCaller-selected LLMProfileStore pathNot 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 processOne installationyes into persisted agent settingsyesOne effective user/settings namespace.
Canvas + DockerOne installationyes same agent-server contractyesDeployment packaging did not add tenancy.
Canvas + personal VM serverOne installationyes same non-Cloud pathyesRemote location did not make it Cloud.
Cloud personal org ownerOrg definitions + owner member settingsyesyesOwner had org-edit permission.
Cloud team adminOrg definitions + admin member settingsyes, but org marker changed for everyoneyesOnly the acting admin’s effective LLM was copied.
Cloud ordinary memberOrg definitions + member settingsno activation authorization failedyesCould view profiles, not edit org state.
ACP conversationACP provider/session model semanticsNot 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

Cloud LLM Profiles land. Canvas gains the Cloud profile catalog, activation request, and the durable-before-conversation-start / scoped-during-conversation behavior reconstructed above. PR #1532 · merge commit 0c06e23a
Agent Profiles become the composer’s conversation-start abstraction. The UI introduces an Agent Profile picker and starts a conversation from its stored llm_profile_ref. PR #1571 · merge commit 96b65f89
Cloud gains organization-owned Agent Profiles with a per-member active pointer. Activation becomes pointer-only; conversation start resolves the profile and its LLM reference. PR #15060 · commit c0c64bbb
Blank conversations are aligned with the Agent Profile picker. Current picker resolution returns Agent Profile before a conversation has substantively started, then LLM Profile for a running OpenHands conversation. PR #1629 · merge commit a411c18d
What got lost. Agent Profiles made conversation-start configuration richer and safer to reuse, but the visible composer stopped being a stable LLM choice. The next page evaluates how to recover that UX without restoring Cloud’s old hybrid ownership bug.
Prepared by OpenHands (AI) on behalf of Engel Nyst. All behavior claims link to pinned source or merged pull requests; proposals live on the companion decision page.