EN / field notes OpenHands feature map

OpenHands / F12

Model router (meta-LLM profiles)

A model router (the UI also calls it a "meta-profile") names a classifier LLM profile, a prompt template and a model table. While a router is active, every new conversation gets a route_task_to_model tool: the classifier picks one saved LLM profile from the table and the agent switches to it (the chat shows Switched to profile <name>). With Run on first message on, the agent is told to route the first user message before doing anything else. Users create routers from the Router Pro / Router Flash templates or from scratch, can have the missing router LLM profiles created from a provider connection, and activate, edit and delete routers under Settings → Model Router.

25 mapped behaviors · 23 recipes and supporting checks · source snapshot 9 October 2026
From upstream main at 8793c111. Read the maintained source.

How to get to it

  • Sidebar settings gear (backend-selector-settings-link), then Model Router in the settings navigation (sidebar-settings-/settings/meta-llm, between LLM and Condenser). Used by F12.page.
  • Direct URL /settings/meta-llm. Used by most recipes (browser goto).
  • Command menu (Control+k or Meta+k, or command-menu-trigger in the sidebar): search "Model Router" (or "router", "meta"), choose Model Router ("Meta-profiles that route each task to the right LLM profile."). Used by F12.page.
  • Per-router actions: the row ... button (meta-profile-menu-trigger-<name>), then Edit / Set active / Delete.
  • The effect of a router is seen in a conversation started from the home composer (F12.route-first-message; see F05/F06 for the composer and activity rendering).

Before you start

Start with the common launch and health checks, then follow this family’s preconditions in order. Recipes share the fixtures and state named below.

Preconditions:

  • F12.empty-states needs a fresh launch --new with onboard --skip done and before llm preset deepseek (no LLM profiles, no routers).
  • Everything after it needs control-openhands llm preset deepseek --api-key-file <key file> (profiles deepseek-flash and deepseek-pro), no meta-profile named QA_* (control-openhands api GET /api/meta-profiles), no provider connection (control-openhands api GET /api/llm/provider-connections is []) and no LLM profile named deepseek-chat (control-openhands api GET /api/profiles).
  • F12.add-connection and F12.route-first-message use the DeepSeek key file; never print it.
  • Blocked here: F12.unsupported (needs an agent server older than software-agent-sdk #3744, for example launch --new --sdk-ref <older release>), F12.cloud (needs a Cloud account, with and without an organization) and F12.load-error (needs /api/meta-profiles to fail while the rest of the agent server answers; service stop agent-server swaps the whole app for the Manage backends card instead).
  • Desktop viewport unless a bullet says otherwise.

Behavior inventory

25 stable behavior IDs and their expected behavior
  • F12.page Settings → Model Router opens /settings/meta-llm with the subtitle Meta-profiles that route each task to the right LLM profile.; the command menu offers a Model Router command that opens it, like the other settings pages. Read recipe ↓
  • F12.empty-states with no LLM profiles the page says Create LLM profiles first — meta-profiles route between your saved LLM profiles.; with no routers it says No meta-profiles yet. Add one to route tasks across your LLM profiles. and Run on first message is off and disabled. Read recipe ↓
  • F12.load-error when only the meta-profile request fails, a red generic error shows under the header.
  • F12.unsupported an agent server without /api/meta-profiles (404) shows This backend doesn't support model routing yet. Update the agent server to manage meta-profiles.
  • F12.cloud a Cloud backend without an organization shows Meta-profiles are only available for local backends.; an organization-bound Cloud backend shows the full page.
  • F12.template-chooser Add Model Router opens Choose Model Router template (Add OpenHands Router Pro, Add OpenHands Router Flash, Custom, Cancel); Cancel, Escape and a backdrop click close it; Pro and Flash prefill the name, classifier minimax-m3, their own long routing prompt and the same 11-model table. Read recipe ↓
  • F12.template-custom Custom opens a blank editor; the placeholders show an example prompt and a two-line example model table. Read recipe ↓
  • F12.editor-validation Save stays disabled until the name is valid, a classifier is set and the prompt contains {{ instance_text }} (spaces inside the braces optional). Read recipe ↓
  • F12.duplicate-name a new router cannot take an existing router's name (A meta-profile with this name already exists..., Save disabled). Read recipe ↓
  • F12.add-connection Add connection in the create editor opens Add provider connection; saving it selects the new connection in the picker. Read recipe ↓
  • F12.add-connection-cancel Cancel in Add provider connection closes it without creating a connection and keeps the editor's values and picker choice. Read recipe ↓
  • F12.editor-cancel while the create or edit editor is open it replaces the list, Add Model Router and the switch; Cancel returns to the list without saving anything. Read recipe ↓
  • F12.router-profiles with a connection picked under Create missing router LLM profiles using, Save creates an LLM profile <provider>/<name> linked to that connection for every model-table name (and the classifier) that does not exist yet; Don't create profiles creates none. Read recipe ↓
  • F12.create-first saving the first router lists it, makes it Active and turns Run on first message on. Read recipe ↓
  • F12.create-more saving another router lists it and leaves the Active badge where it was. Read recipe ↓
  • F12.list each row shows the name, <classifier> · Direct prompt, an Active badge on the active router and a ... menu; routers and the active choice survive a stack restart. Read recipe ↓
  • F12.row-menu the row menu offers Edit, Set active (disabled on the active row) and Delete, and closes on Escape. Read recipe ↓
  • F12.activate Set active toasts Meta-profile "<name>" activated and moves the badge. Read recipe ↓
  • F12.edit Edit opens Edit meta-profile with the name locked and no connection picker; changes persist. Read recipe ↓
  • F12.delete Delete asks Are you sure you want to delete "<name>"? This cannot be undone.; Cancel keeps the row, Delete removes it. Read recipe ↓
  • F12.delete-active deleting the active router leaves no router active; the switch shows off and disabled, and new conversations get neither the tool nor the first-message instruction. Read recipe ↓
  • F12.run-first-message-toggle the switch saves immediately (no Save button, no toast) and survives a reload. Read recipe ↓
  • F12.route-first-message with a router active and the switch on, a new conversation routes its first message (route_task_to_model action, Switched to profile <name> in the chat); with the switch off the tool is still attached but the instruction is absent. Read recipe ↓
  • F12.light-theme in a light colour theme the list heading and router names are readable. Read recipe ↓
  • F12.phone the list, template modal, editor and row menu fit a 390 px viewport without horizontal overflow. Read recipe ↓

Readable recipes

Read each script from top to bottom. Code is copied from the map; prose gives the action, expected observation, and conditions. <id>, <run> and similar placeholders stand for values from your own run. Short forms such as browser count continue the same control-openhands invocation; they are kept as documented.

Expected observations describe the recipe’s contract. Captures below selected recipes show representative real states from this snapshot; they do not mark every mapped behavior as passed. Follow cleanup before moving to another family.

Open from navigation #

  1. Note
    From / run
  2. Check
    control-openhands browser click 'testid=backend-selector-settings-link' --expect-url '/settings'
  3. Check
    control-openhands browser click 'testid=sidebar-settings-/settings/meta-llm' --expect-url '/settings/meta-llm(\?|$)'
  4. Check
    control-openhands browser text 'testid=settings-page-subtitle'
  5. Check
    control-openhands browser attr 'testid=sidebar-settings-/settings/meta-llm' aria-current
  6. Expect
    The subtitle is Meta-profiles that route each task to the right LLM profile. and aria-current is page.

Command menu #

  1. Do
    control-openhands browser goto /

    (the previous bullet ends on /settings/meta-llm, where the URL wait would pass at once),

  2. Do
    control-openhands browser press Control+k
  3. Do
    control-openhands browser type 'testid=command-menu >> role=combobox' 'Model Router'
  4. Check
    control-openhands browser snapshot 'testid=command-menu'
  5. Note
    : the only option is Model Router Meta-profiles that route each task to the right LLM profile. Go [selected].
  6. Do
    control-openhands browser fill 'testid=command-menu >> role=combobox' router

    (and then meta) keeps that single option (control-openhands browser count 'testid=command-menu >> role=option' is 1).

  7. Do
    control-openhands browser press Enter
  8. Wait
    control-openhands browser wait-url '/settings/meta-llm(\?|$)'
  9. Note
    browser text 'testid=settings-page-subtitle' is the subtitle above.

Empty states #

  1. Note
    On the fresh run run
  2. Do
    control-openhands browser goto /settings/meta-llm
  3. Check
    control-openhands browser text 'testid=meta-profile-no-llm-profiles'
  4. Check
    control-openhands browser text 'testid=meta-profile-empty'
  5. Do
    control-openhands browser eval "(()=>{const i=document.querySelector('[data-testid=meta-profile-run-at-conversation-start-switch]');return {checked:i.checked,disabled:i.disabled}})()"
  6. Expect
    The texts are the two messages in Sub-features and the switch is {checked:false, disabled:true}.
  7. Do
    control-openhands browser screenshot --feature F12.empty-states --name no-llm-profiles

    shows both messages above a greyed switch.

Template chooser #

  1. Do
    control-openhands browser click 'testid=add-meta-profile'
  2. Check
    control-openhands browser snapshot 'role=dialog'
  3. Note
    : dialog Choose Model Router template with buttons Add OpenHands Router Pro, Add OpenHands Router Flash, Custom, Cancel.
  4. Do
    control-openhands browser click 'testid=meta-profile-template-cancel'
  5. Check
    control-openhands browser count 'testid=meta-profile-template-modal'

    is 0; reopen and

  6. Do
    control-openhands browser press Escape
  7. Note
    gives 0 too.
  8. Note
    Reopen,
  9. Do
    control-openhands browser click 'testid=meta-profile-template-router-pro'
  10. Check
    control-openhands browser value 'testid=meta-profile-name-input'

    (openhands-router-pro),

  11. Check
    control-openhands browser value 'testid=meta-profile-classifier-input'

    (minimax-m3),

  12. Check
    control-openhands browser eval "document.querySelector('[data-testid=meta-profile-model-table]').value.split('\n').length"

    (11),

  13. Check
    control-openhands browser eval "document.querySelector('[data-testid=meta-profile-prompt-template]').value.includes('{{ instance_text }}')"

    (true),

  14. Check
    control-openhands browser value 'testid=meta-profile-router-connection'

    (Don't create profiles while no connection exists) and

  15. Check
    control-openhands browser enabled 'testid=meta-profile-save'

    (true).

  16. Note
    Leave with
  17. Do
    control-openhands browser click 'testid=meta-profile-cancel'
  18. Expect
    The Flash template (testid=meta-profile-template-router-flash) gives openhands-router-flash, the same classifier and identical table, and a different prompt (about 5,900 characters against Pro's 5,000; compare with browser eval "document.querySelector('[data-testid=meta-profile-prompt-template]').value.length").
  19. Expect
    A backdrop click also closes the chooser: reopen it and
  20. Do
    control-openhands browser mouse-click 20 980
  21. Note
    then count is 0.
Choose Model Router template dialog offers OpenHands Router Pro, OpenHands Router Flash, Custom and Cancel.
Start a router from a bundled template or write a custom configuration. CLI capture · 1440 × 1000 · 9 October 2026 · Canvas 8793c111

Choose Model Router template dialog offers OpenHands Router Pro, OpenHands Router Flash, Custom and Cancel.

Representative documentation capture, not a complete run of this recipe or family. Captured on an isolated local backend at desktop viewport using the current main checkout.

How this screenshot was taken

canvas: 1.26.0 · agent server: 1.53.0 · sdk: 1.53.0 · automation: 1.19.0

OH_VERIFY_RUN="$OH_VERIFY_RUN" control-openhands browser goto /settings/meta-llm
OH_VERIFY_RUN="$OH_VERIFY_RUN" control-openhands browser click testid=add-meta-profile
OH_VERIFY_RUN="$OH_VERIFY_RUN" control-openhands browser snapshot role=dialog
OH_VERIFY_RUN="$OH_VERIFY_RUN" control-openhands browser screenshot --feature F12.template-chooser --name router-template-dialog

Custom template #

  1. Do
    control-openhands browser click 'testid=add-meta-profile'
  2. Do
    control-openhands browser click 'testid=meta-profile-template-custom'
  3. Check
    control-openhands browser value
  4. Note
    on testid=meta-profile-name-input, testid=meta-profile-classifier-input, testid=meta-profile-prompt-template and testid=meta-profile-model-table (all ""),
  5. Check
    control-openhands browser enabled 'testid=meta-profile-save'

    (false) and

  6. Do
    control-openhands browser eval "document.querySelector('[data-testid=meta-profile-model-table]').placeholder.split('\n')"
  7. Note
    : two example rows, ["- GPT-5.4: strong for coding and reasoning", "- MiniMax-M3: efficient fallback"], joined by a real line break (#18035), and
  8. Do
    control-openhands browser screenshot --feature F12.template-custom --name custom-empty

    shows them on two lines under the prompt placeholder, which breaks lines the same way.

Validation #

  1. Note
    In that Custom editor run
  2. Do
    control-openhands browser fill 'testid=meta-profile-name-input' 'QA router!'
  3. Do
    control-openhands browser fill 'testid=meta-profile-classifier-input' qa-classifier
  4. Do
    control-openhands browser fill 'testid=meta-profile-prompt-template' 'Pick a model for: {{ instance_text }}'
  5. Check
    control-openhands browser enabled 'testid=meta-profile-save'

    (false: bad name).

  6. Do
    control-openhands browser fill 'testid=meta-profile-name-input' QA_router
  7. Note
    turns it true;
  8. Do
    control-openhands browser fill 'testid=meta-profile-prompt-template' 'Pick a model for: {{ task }}'
  9. Note
    turns it false; 'Pick a model for: {{instance_text}}' turns it true again;
  10. Do
    control-openhands browser fill 'testid=meta-profile-classifier-input' ''
  11. Note
    turns it false.
  12. Note
    Keep the editor open.

Add a connection #

  1. Do
    control-openhands browser click 'testid=meta-profile-add-provider-connection'
  2. Do
    control-openhands browser fill 'testid=provider-connection-name-input' QA_deepseek
  3. Do
    control-openhands browser fill 'testid=provider-connection-provider-input' deepseek
  4. Do
    control-openhands browser click 'role=option[name="deepseek"]'
  5. Check
    control-openhands browser fill 'testid=provider-connection-api-key-input' --value-file <key file>
  6. Do
    control-openhands browser click 'testid=provider-connection-submit'
  7. Wait
    control-openhands browser wait 'testid=provider-connection-modal' --state detached
  8. Check
    control-openhands browser toasts

    has Connection "QA_deepseek" created and

  9. Check
    control-openhands browser value 'testid=meta-profile-router-connection'

    is QA_deepseek (deepseek).

Create the first router #

  1. Note
    Write the prompt and table to files: printf 'You route tasks. Reply only with JSON {"model": "<name>", "reason": "<short>"} using one name from this table:\n{{ model_table }}\n\nTask:\n{{ instance_text }}' > <prompt file> and printf -- '- deepseek-flash: cheap, for simple tasks\n- deepseek-pro: stronger, for hard tasks\n- deepseek-chat: general chat' > <table file>.
  2. Do
    control-openhands browser click 'testid=meta-profile-classifier-input'
  3. Do
    control-openhands browser click 'role=option[name="deepseek-flash"]'
  4. Do
    control-openhands browser fill 'testid=meta-profile-name-input' QA_router
  5. Check
    control-openhands browser fill 'testid=meta-profile-prompt-template' --value-file <prompt file>
  6. Check
    control-openhands browser fill 'testid=meta-profile-model-table' --value-file <table file>
  7. Do
    control-openhands browser click 'testid=meta-profile-save'
  8. Wait
    control-openhands browser wait-text 'Meta-profile "QA_router" saved'
  9. Note
    After
  10. Do
    control-openhands browser reload
  11. Check
    control-openhands browser text 'testid=meta-profile-row-QA_router'

    is QA_router\ndeepseek-flash · Direct prompt\nActive, the switch eval above returns {checked:true, disabled:false},

  12. Check
    control-openhands api GET /api/meta-profiles

    has active_meta_profile QA_router,

  13. Check
    control-openhands api GET /api/settings

    has "run_router_at_conversation_start": true, and

  14. Check
    control-openhands api GET /api/profiles

    has a new deepseek-chat (deepseek/deepseek-chat, provider_connection_id set) while deepseek-flash/deepseek-pro are unchanged.

  15. Do
    control-openhands browser screenshot --feature F12.create-first --name list-after-create

    shows the row with the gold Active badge and the switch on.

Duplicate name #

  1. Do
    control-openhands browser click 'testid=add-meta-profile'
  2. Do
    control-openhands browser click 'testid=meta-profile-template-custom'
  3. Do
    control-openhands browser fill 'testid=meta-profile-name-input' QA_router
  4. Check
    control-openhands browser text 'testid=meta-profile-name-taken'

    (A meta-profile with this name already exists. Choose a different name, or edit the existing one.), pick classifier deepseek-pro as above, fill the prompt with 'Route: {{ instance_text }}', and

  5. Check
    control-openhands browser enabled 'testid=meta-profile-save'

    (false).

  6. Do
    control-openhands browser fill 'testid=meta-profile-name-input' QA_router_2
  7. Note
    makes
  8. Check
    control-openhands browser count 'testid=meta-profile-name-taken'
  9. Note
    0 and Save true.
  10. Note
    Click testid=meta-profile-cancel;
  11. Check
    control-openhands browser count 'testid=meta-profile-row-QA_router_2'

    is 0.

"Don't create profiles" in a template #

  1. Do
    control-openhands browser click 'testid=add-meta-profile'
  2. Do
    control-openhands browser click 'testid=meta-profile-template-router-pro'
  3. Check
    control-openhands browser value 'testid=meta-profile-router-connection'

    (QA_deepseek (deepseek), preselected),

  4. Do
    control-openhands browser click 'testid=meta-profile-router-connection'
  5. Do
    control-openhands browser click "role=option[name=\"Don't create profiles\"]" --observe 'testid=meta-profile-router-connection' --observe-ms 1500
  6. Expect
    The picker stays on Don't create profiles: the observed states are QA_deepseek (deepseek) at 0 ms and Don't create profiles about 300 ms later, with nothing after it, and
  7. Check
    control-openhands browser value 'testid=meta-profile-router-connection'

    is Don't create profiles.

  8. Note
    Cancel the editor (control-openhands browser click 'testid=meta-profile-cancel'); F12.create-more saves a template with this choice.
  9. Expect
    The Custom template starts on Don't create profiles even with a connection present; there, pick role=option[name="QA_deepseek (deepseek)"] first and then Don't create profiles: the second pick sticks.
  10. Note
    Cancel the editor.

Create another router without new profiles #

  1. Do
    control-openhands browser click 'testid=add-meta-profile'
  2. Do
    control-openhands browser click 'testid=meta-profile-template-router-flash'
  3. Check
    control-openhands browser value 'testid=meta-profile-router-connection'

    (QA_deepseek (deepseek), preselected),

  4. Do
    control-openhands browser fill 'testid=meta-profile-name-input' QA_router_flash
  5. Do
    control-openhands browser click 'testid=meta-profile-router-connection'
  6. Do
    control-openhands browser click "role=option[name=\"Don't create profiles\"]"
  7. Check
    control-openhands browser value 'testid=meta-profile-router-connection'

    (Don't create profiles).

  8. Do
    control-openhands browser click 'testid=meta-profile-save'
  9. Wait
    control-openhands browser wait-text 'Meta-profile "QA_router_flash" saved'
  10. Note
    After
  11. Do
    control-openhands browser reload
  12. Check
    control-openhands browser text 'testid=meta-profile-list'

    is QA_router\ndeepseek-flash · Direct prompt\nActive\nQA_router_flash\nminimax-m3 · Direct prompt and

  13. Check
    control-openhands browser count 'testid=meta-profile-active-badge'

    is 1.

  14. Check
    control-openhands api GET /api/profiles
  15. Note
    still lists only deepseek-chat, deepseek-flash and deepseek-pro: none of the template's 11 models became a profile, so QA_router_flash names a classifier (minimax-m3) that does not exist, which saving allows (see Gotchas).

Row menu and activate #

  1. Do
    control-openhands browser click 'testid=meta-profile-menu-trigger-QA_router'
  2. Check
    control-openhands browser snapshot 'testid=meta-profile-actions-menu'

    (menuitem "Edit", menuitem "Set active" [disabled], menuitem "Delete");

  3. Do
    control-openhands browser press Escape
  4. Note
    closes it (count 0).
  5. Do
    control-openhands browser click 'testid=meta-profile-menu-trigger-QA_router_flash'
  6. Do
    control-openhands browser click 'testid=meta-profile-actions-menu >> testid=meta-profile-set-active'
  7. Wait
    control-openhands browser wait-text 'Meta-profile "QA_router_flash" activated'
  8. Note
    After
  9. Do
    control-openhands browser reload
  10. Check
    control-openhands browser count 'testid=meta-profile-row-QA_router_flash >> testid=meta-profile-active-badge'

    is 1, the total badge count is 1, and api GET /api/meta-profiles agrees.

Edit #

  1. Do
    control-openhands browser click 'testid=meta-profile-menu-trigger-QA_router'
  2. Do
    control-openhands browser click 'testid=meta-profile-actions-menu >> testid=meta-profile-edit'
  3. Check
    control-openhands browser text 'testid=meta-profile-editor >> role=heading'

    (Edit meta-profile),

  4. Check
    control-openhands browser enabled 'testid=meta-profile-name-input'

    (false),

  5. Check
    control-openhands browser count 'testid=meta-profile-router-connection'

    (0).

  6. Note
    Pick classifier deepseek-pro, fill testid=meta-profile-model-table with the first two table lines only, click testid=meta-profile-save and
  7. Wait
    control-openhands browser wait-text 'Meta-profile "QA_router" saved'
  8. Expect
    After a reload the row reads QA_router\ndeepseek-pro · Direct prompt, the badge stays on QA_router_flash, and
  9. Check
    control-openhands api GET /api/meta-profiles/QA_router

    shows the new classifier and table.

Cancel the connection dialog #

  1. Do
    control-openhands browser click 'testid=add-meta-profile'
  2. Do
    control-openhands browser click 'testid=meta-profile-template-custom'
  3. Do
    control-openhands browser fill 'testid=meta-profile-name-input' QA_router_cancel
  4. Do
    control-openhands browser click 'testid=meta-profile-add-provider-connection'
  5. Do
    control-openhands browser fill 'testid=provider-connection-name-input' QA_unused
  6. Do
    control-openhands browser click 'role=dialog >> role=button[name="Cancel"]'

    (the Cancel button has no test id) and

  7. Wait
    control-openhands browser wait 'testid=provider-connection-modal' --state detached
  8. Note
    browser value 'testid=meta-profile-name-input' is still QA_router_cancel, the picker still reads Don't create profiles, and
  9. Check
    control-openhands api GET /api/llm/provider-connections

    has no QA_unused (display_name is the field).

Leave the editor #

  1. Note
    In that editor
  2. Check
    control-openhands browser count
  3. Note
    on testid=meta-profile-list, testid=add-meta-profile and testid=meta-profile-run-at-conversation-start is 0 each.
  4. Note
    Pick classifier deepseek-flash, fill the prompt 'Pick: {{ instance_text }}', click testid=meta-profile-cancel: count 'testid=meta-profile-list' is 1, count 'testid=meta-profile-row-QA_router_cancel' is 0 and api GET /api/meta-profiles has no QA_router_cancel.
  5. Note
    In edit mode (row menu → Edit on QA_router),
  6. Do
    control-openhands browser fill 'testid=meta-profile-prompt-template' 'Changed: {{ instance_text }}'
  7. Note
    then Cancel leaves
  8. Check
    control-openhands api GET /api/meta-profiles/QA_router
  9. Note
    with its saved prompt_template.

Run on first message #

  1. Do
    control-openhands browser click 'testid=meta-profile-run-at-conversation-start >> text=Run on first message'
  2. Do
    control-openhands browser reload
  3. Note
    and the switch eval: checked flips and stays flipped; api GET /api/settings shows run_router_at_conversation_start with the same value;
  4. Check
    control-openhands browser toasts

    is empty.

  5. Note
    Click again to restore true.

Routing in a conversation #

  1. Note
    Make QA_router active (row menu → Set active, wait-text 'Meta-profile "QA_router" activated') with the switch on.
  2. Wait
    control-openhands conversation start --prompt "Run echo routed in the terminal and tell me its output." --wait --timeout 300
  3. Check
    control-openhands conversation events <id> --kinds ActionEvent,ObservationEvent,MessageEvent

    (<id> from the start output).

  4. Expect
    The first action is route_task_to_model, its observation starts Classified task as 'model: deepseek-flash' and switched to LLM profile 'deepseek-flash', then the terminal runs echo routed.
  5. Note
    On the conversation page
  6. Wait
    control-openhands browser wait-text 'Switched to profile'
  7. Note
    succeeds and
  8. Do
    control-openhands browser screenshot --feature F12.route-first-message --name conversation

    shows the Switched to profile deepseek-flash line above the answer.

  9. Check
    control-openhands api GET "/api/conversations/<id>/events/search?limit=1" --max-bytes 200000

    (the system prompt) contains ROUTE_AT_CONVERSATION_START and "route_task_to_model".

  10. Note
    Switch Run on first message off (control-openhands browser goto /settings/meta-llm first: the browser is on the conversation), start --prompt "Reply with only the word pong.", and the same GET contains "route_task_to_model" but no ROUTE_AT_CONVERSATION_START; there is no route action.

Delete #

  1. Do
    control-openhands browser goto /settings/meta-llm
  2. Do
    control-openhands browser click 'testid=meta-profile-menu-trigger-QA_router_flash'
  3. Do
    control-openhands browser click 'testid=meta-profile-actions-menu >> testid=meta-profile-delete'
  4. Check
    control-openhands browser snapshot 'role=dialog'

    (Delete meta-profile, Are you sure you want to delete "QA_router_flash"? This cannot be undone., Cancel, Delete).

  5. Do
    control-openhands browser click 'role=dialog >> role=button[name="Cancel"]'
  6. Note
    keeps the row (count 1).
  7. Note
    Repeat the menu click, then
  8. Do
    control-openhands browser click 'testid=delete-meta-profile-confirm'
  9. Wait
    control-openhands browser wait-text 'Meta-profile "QA_router_flash" deleted'
  10. Do
    control-openhands browser reload
  11. Check
    control-openhands browser count 'testid=meta-profile-row-QA_router_flash'

    is 0.

Delete the active router #

  1. Note
    With QA_router active and the switch on, delete it the same way (wait-text 'Meta-profile "QA_router" deleted').
  2. Expect
    After a reload testid=meta-profile-empty is back, the switch eval is {checked:false, disabled:true} and api GET /api/meta-profiles has active_meta_profile null, while api GET /api/settings still has "run_router_at_conversation_start": true.
  3. Expect
    A new conversation start --prompt "Reply with only the word pong." --wait has neither "route_task_to_model" nor ROUTE_AT_CONVERSATION_START in its events/search output.

Light theme #

  1. Expect
    No router is left after F12.delete-active, whose last conversation start left the browser on a conversation: run
  2. Do
    control-openhands browser goto /settings/meta-llm
  3. Note
    and create one through Custom as in F12.phone (name QA_router_light, classifier deepseek-flash, prompt 'Pick one model for: {{ instance_text }}', wait-text 'Meta-profile "QA_router_light" saved'), then
  4. Do
    control-openhands browser goto /settings/app
  5. Do
    control-openhands browser click 'testid=color-theme-input'
  6. Do
    control-openhands browser click 'role=option[name="Light+"]'
  7. Do
    control-openhands browser goto /settings/meta-llm
  8. Do
    control-openhands browser eval "(()=>{const h=[...document.querySelectorAll('h2')].find(e=>e.textContent.includes('Available meta-profiles'));const n=document.querySelector('[data-testid^=meta-profile-row-] span');return {heading:getComputedStyle(h).color,rowName:getComputedStyle(n).color,bg:getComputedStyle(document.body).backgroundColor}})()"
  9. Expect
    It returns {heading: "rgb(31, 31, 31)", rowName: "rgb(31, 31, 31)", bg: "rgb(255, 255, 255)"}: dark text, the same ink as LLM profile names on /settings/llm.
  10. Do
    control-openhands browser screenshot --feature F12.light-theme --name list

    shows the heading Available meta-profiles and the name QA_router_light next to its summary and Active badge.

  11. Note
    Restore with role=option[name="OpenHands-Neutral"] the same way, then delete QA_router_light through its row menu (wait-text 'Meta-profile "QA_router_light" deleted') so F12.phone starts on a router-less page.

Phone (F12.phone, also re-proves F12.create-first on a router-less page) #

  1. Do
    control-openhands browser viewport phone
  2. Do
    control-openhands browser goto /settings/meta-llm
  3. Do
    control-openhands browser click 'testid=add-meta-profile'
  4. Check
    control-openhands browser bbox 'testid=meta-profile-template-modal'

    (insideViewport true, pageHorizontalOverflow false).

  5. Note
    Choose Custom, fill QA_router_phone, classifier deepseek-flash and prompt 'Pick one model for: {{ instance_text }}';
  6. Check
    control-openhands browser bbox 'testid=meta-profile-editor'

    has no page overflow (taller than the screen, scrolls).

  7. Note
    Save, reload, then
  8. Check
    control-openhands browser bbox 'testid=meta-profile-row-QA_router_phone'
  9. Note
    and, after
  10. Do
    control-openhands browser click 'testid=meta-profile-menu-trigger-QA_router_phone'
  11. Check
    control-openhands browser bbox 'testid=meta-profile-actions-menu'

    are inside the viewport without page overflow.

  12. Do
    control-openhands browser screenshot --feature F12.phone --name list

    shows QA_route… truncated, the summary, the badge and the switch on.

  13. Note
    Return with
  14. Do
    control-openhands browser viewport desktop

Restart persistence #

  1. Do
    control-openhands restart
  2. Check
    control-openhands doctor
  3. Do
    control-openhands browser goto /settings/meta-llm
  4. Check
    control-openhands browser count 'testid=meta-profile-row-QA_router_phone >> testid=meta-profile-active-badge'
  5. Note
    it is 1.

Cleanup #

  1. Note
    Switch Run on first message off through its label first (it disables once no router is left), then delete each QA_* router through the row menu.
  2. Note
    Remove the arranged LLM profile and connection with
  3. Arrange
    control-openhands api DELETE /api/profiles/deepseek-chat --write
  4. Arrange
    control-openhands api DELETE /api/llm/provider-connections/<connection id> --write

    (<connection id> from control-openhands api GET /api/llm/provider-connections), or through Settings → LLM (F10).

Gotchas and known limits

  • Settings nav test ids embed the route: quote 'testid=sidebar-settings-/settings/meta-llm' as written.
  • The empty-state hint about LLM profiles only exists before any profile is saved: drive F12.empty-states first on a fresh run.
  • The first router you create is activated and turns Run on first message on by itself; later routers do neither. Any recipe that creates a router on an empty list changes both.
  • Run on first message is a hidden <input> (#17900): click the label text inside testid=meta-profile-run-at-conversation-start and read .checked/.disabled with browser eval. The stored preference (misc_settings.app_preferences.run_router_at_conversation_start) is independent of what the disabled switch shows; with no active router it may still be true. Turn it off before deleting the last router, or a later "first router" will not re-enable it because it is already on.
  • Router templates name 11 models (gpt-5.4 … claude-sonnet-4-5) that will not exist in a sandbox. With a provider connection present, a template preselects it, and saving creates all 11 as LLM profiles linked to that connection unless you pick Don't create profiles first (F12.create-more does). If a run created them, remove them with for p in gpt-5.4 gpt-5.5 minimax-m3 claude-opus-4-6 claude-opus-4-7 claude-opus-4-8 claude-fable-5 kimi-k2.5 kimi-k2.6 deepseek-v3.2-reasoner claude-sonnet-4-5; do control-openhands api DELETE /api/profiles/$p --write; done (they live in the run's private state and only add rows to Settings → LLM).
  • Profile matching is case-insensitive and exact by name: table rows are - <profile name>: description; existing deepseek-flash/deepseek-pro are not re-created, and the endpoint is derived as <provider lowercased>/<name>.
  • The classifier and the model table refer to LLM profile names, not model ids. A router whose classifier profile does not exist (for example a template's minimax-m3 without a connection) saves fine and only fails when a conversation tries to route.
  • The agent decides whether to call route_task_to_model: a prompt that conflicts with routing (Reply with only the word pong.) made deepseek-flash skip the call even with the instruction present. Use a task-like prompt for the positive check and assert the system prompt via events/search for the instruction itself.
  • conversation events does not print the system prompt; read it with control-openhands api GET "/api/conversations/<id>/events/search?limit=1" --max-bytes 200000 and grep the output.
  • service stop agent-server does not show this page's error state: the whole app switches to the Manage backends card (F02). Run control-openhands restart afterwards.
  • The selection in the classifier and connection pickers is a combobox: open it with a click on the input, then click role=option[name="..."]; browser fill on the classifier sets a free-text value (allowed) instead of picking a profile.
  • At 390 px the row truncates the name (QA_route…) and summary; read names with browser text, not the screenshot.
  • The Model table placeholder showed a literal \n on one line until #18035 (closed #17939); a checkout without that fix still does (F12.template-custom).

Source paths: src/routes/meta-llm-settings.tsx, src/components/features/settings/meta-llm-profiles/, src/api/meta-profiles-service/meta-profiles-service.api.ts, src/api/agent-server-adapter.ts (buildRouterAtStartSystemSuffix), src/constants/settings-nav.tsx.