diff --git a/docs/design/agent-workflows/documentation/agent-configuration.md b/docs/design/agent-workflows/documentation/agent-configuration.md
index 521b78f6ac..89e9d4937d 100644
--- a/docs/design/agent-workflows/documentation/agent-configuration.md
+++ b/docs/design/agent-workflows/documentation/agent-configuration.md
@@ -55,16 +55,20 @@ existing control:
- `agents_md` renders as a multiline text input labeled "Instructions". It falls back to a
legacy `instructions` value when `agents_md` is missing.
-- `model` renders as a unified, harness-filtered provider + model picker. The options come from
- the agent's `/inspect` `meta.harness_capabilities[harness].models` (Pi: the vault providers'
- catalog ids; Claude: its aliases), not the full shared catalog. Selecting a model sets BOTH the
- model id and its provider, so there is no separate provider field. When `/inspect` publishes no
- per-harness models (older agents / a standalone control), it falls back to the schema's full
- grouped-choice catalog. Below the picker, an **Authentication** toggle chooses *Agenta-managed*
- (a vault connection — "Project default" or a named connection picked from `GET /secrets/`) vs
- *Self-managed* (the harness uses its own login; Agenta injects nothing). The form always writes
- `model` as a structured `ModelRef` (`{provider, model, connection?}`), never a free-text string;
- the connection rides in `model_ref.connection`. A raw-JSON escape hatch remains for power users.
+- `model` renders in its own "Model" section: a harness-filtered provider + model picker. The
+ options come from the agent's `/inspect` `meta.harness_capabilities[harness].models` (Pi: the
+ vault providers' catalog ids; Claude: its aliases), not the full shared catalog. Selecting a
+ model sets BOTH the model id and its provider, so there is no separate provider field. When
+ `/inspect` publishes no per-harness models (older agents / a standalone control), it falls back
+ to the schema's full grouped-choice catalog. A separate **Provider credentials** section, not
+ nested under the model picker, holds the connection mode: a segmented toggle with *Use API key*
+ (a vault connection, either "Project default" or a named connection picked from
+ `GET /secrets/`) and *Use subscription* (the harness signs itself in, using a Claude Code or
+ Codex subscription or any credentials it reads from its own environment such as environment
+ variables; Agenta injects nothing and this mode requires a self-hosted deployment). The form
+ always writes `model` as a structured `ModelRef` (`{provider, model, connection?}`), never a
+ free-text string; the connection rides in `model_ref.connection`. A raw-JSON escape hatch
+ remains for power users.
- `tools` renders as a flat array. Each entry uses `ToolItemControl`, the same tool object
shape the prompt control uses.
- `mcp_servers` renders as a flat array. Each entry uses `McpServerItemControl`, which is a
diff --git a/docs/design/connect-model-drawer/README.md b/docs/design/connect-model-drawer/README.md
new file mode 100644
index 0000000000..af7c53bfe4
--- /dev/null
+++ b/docs/design/connect-model-drawer/README.md
@@ -0,0 +1,29 @@
+# Connect a Model drawer redesign
+
+Planning workspace for redesigning the agent playground's "Model & harness" section
+drawer after the claude.design prototype "Connect a Model Flow", with the product
+owner's final decisions applied (three sections, a "Use API key" / "Use subscription"
+credentials toggle, inline custom providers, an unsaved-changes guard, and a readable
+selection style for the shared SectionRail).
+
+Status: **implemented (slices 1-6), pending review and live verification**. See
+status.md for details.
+
+## Files
+
+| File | What it holds |
+| --- | --- |
+| [context.md](context.md) | Why the work exists, the owner's locked decisions, scope, and constraints (uncommitted in-flight changes, package layering, dark mode). Read this first. |
+| [research.md](research.md) | Verified code map with file/line citations, the confirmed `--ag-colorPrimaryBg` root cause, SectionRail blast radius, the extracted design prototype, and the hex → theme-token mapping. |
+| [design.md](design.md) | Component-level design: drawer structure, the SectionRail restyle, the ProviderCredentialsSection and its props, the CustomProviderForm extraction (with required package moves), the unsaved-changes guard, Advanced-tab removal, gating matrix, and final copy strings. |
+| [plan.md](plan.md) | Six implementation slices sized for Sonnet subagents, in dependency order, plus the dev-stack verification plan (light + dark). |
+| [status.md](status.md) | Source of truth for progress, the open decisions D1-D6, locked decisions, and next steps. Keep it updated. |
+
+## Quick orientation
+
+- Drawer host: `web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx`
+- Stateful core: `.../SchemaControls/agentTemplate/useModelHarness.tsx`
+- Shared rail: `web/packages/agenta-entity-ui/src/drawers/shared/SectionRail.tsx`
+- Secrets: `web/packages/agenta-entities/src/secret/`
+- Custom-provider form (extraction source): `web/oss/src/components/ModelRegistry/Drawers/ConfigureProviderDrawer/`
+- Design prototype: `Agenta onboarding flow redesign (1)/Connect a Model Flow.dc.html`
diff --git a/docs/design/connect-model-drawer/context.md b/docs/design/connect-model-drawer/context.md
new file mode 100644
index 0000000000..f63f56843b
--- /dev/null
+++ b/docs/design/connect-model-drawer/context.md
@@ -0,0 +1,89 @@
+# Context: Connect a Model drawer redesign
+
+## What this project is
+
+Redesign the "Model & harness" section drawer in the agent playground's left config panel.
+The redesign follows the claude.design prototype "Connect a Model Flow" (saved at
+`Agenta onboarding flow redesign (1)/Connect a Model Flow.dc.html` in the repo root).
+
+The drawer today mixes model, harness, and credential concerns across two tabs plus a
+separate Advanced drawer. The redesign gives the drawer three clear sections (Harness,
+Model, Provider credentials), moves the connection-mode choice out of Advanced into the
+credentials section, lets users add custom providers inline without a nested drawer, and
+stops silently discarding unsaved edits on an outside click.
+
+## Why now
+
+The agent onboarding flow funnels new users into this drawer (the chat's
+"Set up credentials" banner opens it). Three problems hurt that flow:
+
+1. **Selection is unreadable.** The harness list's selected row shows only bold text in
+ light mode. Root cause: the shared `SectionRail` styles its active row with
+ `var(--ag-colorPrimaryBg)`, a token the theme generator never emits, so the background
+ is transparent (see research.md).
+2. **Credentials are scattered.** The API-key field hides behind a "Provider key" rail
+ tab keyed to the selected model, and the Agenta-managed vs self-managed choice hides in
+ the Advanced drawer. A user connecting their first key has to find both.
+3. **Silent data loss.** Clicking the scrim closes the drawer and throws away the draft
+ with no warning.
+
+## Product owner's decisions (final, locked)
+
+These came from the product owner and are not open for redesign. The plan must implement
+them as stated.
+
+1. **Harness section: no layout or behavior change.** The only change is the selection
+ styling of its list rows (the shared `SectionRail` recipe). The design's dropdown
+ harness picker is NOT adopted.
+2. **Provider credentials section** with a two-option segmented toggle:
+ **"Use API key" / "Use subscription"**.
+ - "Use API key": provider rail on the left (standard providers with colored logo
+ tiles, "+ Custom provider" pinned at the bottom), credential form on the right for
+ the selected provider.
+ - Custom providers render their form INSIDE the right pane (card content swap). No
+ nested drawer, no stacked drawers, no pixel shifts. The existing custom-provider form
+ logic must be extracted into a reusable component that both the old surfaces
+ (`ConfigureProviderDrawer`) and this pane share.
+ - **Key saves stay immediate** (per-provider save button writing the vault, as today
+ via `useVaultSecret` / `createStandardSecretAtom`). The drawer's footer Save commits
+ only the agent config draft (harness / model / mode). LOCKED.
+ - "Use subscription": the rail and key form disappear; the pane becomes one info card.
+ The toggle label says "Use subscription"; INSIDE the card the heading and language
+ are "Self-managed" (the harness signs itself in: a Claude Code or Codex subscription
+ OR any harness-side auth such as environment variables). Mention the self-hosting
+ requirement; show a "Not on cloud" badge when gated.
+ - The toggle is capability-gated via the harness catalog's `allowedConnectionModes`,
+ plus deployment gating on cloud.
+ - Selecting the mode here REPLACES the mode selector in the Advanced drawer. The
+ Agenta-managed/Self-managed `SectionRail` and the named-connection `Select` leave
+ Advanced entirely. Nothing mode-related remains in Advanced.
+3. **Model section is its own section**, separate from credentials. Recommended order
+ (flagged as a decision, default to it): Harness → Model → Provider credentials. When a
+ model is picked, auto-highlight its provider in the credentials rail.
+4. **Unsaved-changes guard.** If the draft is dirty and the user closes without Save or
+ Cancel (scrim click, X), show a confirm modal: save / discard / keep editing.
+5. **Copy alignment.** The "self-managed" wording (accurate beyond subscriptions) must
+ also be used in the harness capability descriptions and any docs this plan touches.
+
+## Not in scope
+
+- Key validation or test calls against providers.
+- Any other drawer sections (Instructions, Tools, MCP servers, Skills, Triggers).
+- The chat `ConnectModelBanner` (already fixed separately).
+- Layout changes to the harness section beyond selection styling.
+
+## Constraints
+
+- **Build on the current working tree.** The touched files carry uncommitted in-flight
+ changes (chat gate logic, `agentCreationPrefsAtom` capture, `providerKeySetupDoneAtom`,
+ the self-managed pill fix in `providerNeedsKey`). Never revert them.
+- **Keep the creation-prefs capture seam working.** `AgentTemplateControl.saveSection`
+ reads the connection mode from `draftConfig.llm` via `connectionFromConfig` when the
+ model-harness section saves. Moving the mode control between sections must not break
+ that read (it won't, as long as the mode keeps living at `config.llm.connection.mode`).
+- **Dark mode must work.** Every design hex maps to an `--ag-*` theme token. The palette
+ source of truth is `web/oss/src/styles/theme/palette.ts` plus the generator; never
+ hand-edit `theme-variables.css` or `antd-overrides.generated.ts`.
+- **Package layering.** The drawer lives in `@agenta/entity-ui`; it cannot import from
+ `web/oss`. Anything the pane needs from the app layer must move into a package or flow
+ through the `DrillInUIContext` bridge.
diff --git a/docs/design/connect-model-drawer/design.md b/docs/design/connect-model-drawer/design.md
new file mode 100644
index 0000000000..617e688d9b
--- /dev/null
+++ b/docs/design/connect-model-drawer/design.md
@@ -0,0 +1,299 @@
+# Design: components, contracts, and copy
+
+Read research.md first for the current-code citations. This file says what we build and
+where. Open decisions are marked D1..D6 and collected in status.md.
+
+## 1. Drawer structure after the redesign
+
+The "Model & harness" `SectionDrawer` body (built by `useModelHarness`,
+`modelHarnessDrawerBody`) becomes three stacked `ConfigAccordionSection`s in this order
+(D1, owner default):
+
+1. **Harness** — unchanged. Same info note, same `harnessSection` SectionRail + detail
+ panel. Only the SectionRail selection styling changes (section 2 below).
+2. **Model** — the model picker moves out of the old "Model & credentials" tabs into its
+ own section. Content: the existing `modelControl`
+ (`SelectLLMProviderBase` with harness-filtered groups, or the `GroupedChoiceControl`
+ fallback) plus the "Filtered to the models this harness can reach…" hint. Status dot:
+ warning when no model or `!selectedKeepsModel`. The `modelTab` state and its
+ auto-forcing effect (`useModelHarness.tsx` lines ~217-220) are deleted; credentials
+ are always visible now.
+3. **Provider credentials** — new two-pane section (section 3 below). Status dot:
+ warning when `providerNeedsKey`.
+
+Rationale for the order: the harness constrains the models, and the model decides which
+provider's credential matters. Picking a model then auto-highlights its provider in the
+credentials rail below it, which reads top-to-bottom. The prototype orders it
+Harness → Credentials → Model; the owner flagged the order as a decision and set this
+as the default.
+
+The right-hand 240px version-history skeleton and the 880px width stay (D3, default:
+keep; the alternative is the prototype's 640px single column, which would be a bigger
+layout change than this scope wants).
+
+The `advancedDrawerBody` loses its entire "Authentication" group (section 6 below).
+
+## 2. SectionRail selection restyle
+
+File: `web/packages/agenta-entity-ui/src/drawers/shared/SectionRail.tsx` (one place).
+
+Replace the active-row classes (line ~72):
+
+```
+before: !bg-[var(--ag-colorPrimaryBg)] !font-medium !text-[var(--ag-colorPrimary)]
+after: !bg-[var(--ag-colorFillSecondary)] !font-semibold !text-[var(--ag-colorText)]
+```
+
+Inactive and hover styles stay as they are (`text-[var(--ag-colorTextSecondary)]`,
+hover `bg-[var(--ag-colorFillTertiary)]` + `text-[var(--ag-colorText)]`), which already
+match the prototype's `#586673` / `#f7f9fb` mapping. Keep `rounded-md`.
+
+This is a **global restyle** (D2, recommended): all five consumers (harness list, drawer
+tabs, auth rail, workflow-reference selector, run-version field, commit modal, agent-home
+templates gallery; see research.md §4) render the same semantic "selected rail item" and
+all are equally broken today, because the active background token does not exist. A
+`variant` prop would preserve a broken look somewhere for no benefit. If any consumer
+looks wrong in review, fall back to a `selectionStyle?: "filled" | "primary"` prop with
+`"filled"` as the default and fix the primary recipe's token.
+
+Verify all five consumers in light AND dark (the tokens carry the dark values).
+
+## 3. The Provider credentials section
+
+New component: `ProviderCredentialsSection` in
+`web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderCredentialsSection.tsx`.
+It stays in the agentTemplate folder (single consumer today; promote later if reused).
+
+### 3.1 Props (classified by semantic role)
+
+```ts
+interface ProviderCredentialsSectionProps {
+ // config (the agent draft's credential-relevant slice)
+ mode: ConnectionMode // config.llm.connection.mode
+ connectionSlug: string | null // config.llm.connection.slug
+ onModeChange: (mode: ConnectionMode) => void // -> writeModel({mode})
+ onConnectionSlugChange: (slug: string | null) => void // -> writeModel({slug})
+
+ // routing/context (what the current selection points at)
+ selectedProviderFamily: string | null // derived from the picked model; auto-highlights the rail
+
+ // policy (what the environment allows)
+ modeOptions: ConnectionMode[] // allowedConnectionModes(capabilities, harness)
+ isCloud: boolean // deployment gate for "Use subscription"
+
+ // presentation
+ disabled?: boolean
+}
+```
+
+Credentials themselves (vault keys, custom providers) are NOT props: the component reads
+`standardSecretsAtom` / `customSecretsAtom` and writes through `useVaultSecret` directly,
+because key saves are immediate and independent of the drawer draft (locked decision).
+Only the agent-config fields (mode, slug) flow through props into the draft.
+
+### 3.2 Layout and behavior
+
+Header row: section title "Provider credentials" + the segmented toggle on the right.
+
+- **Toggle**: antd `Segmented` (theme-aware; do not hand-roll a navy fill, dark mode's
+ primary is yellow). Options: "Use API key" (`agenta`), "Use subscription"
+ (`self_managed`).
+ - Hidden entirely when `modeOptions.length < 2` and the only mode is `agenta`
+ (harness does not support self-managed).
+ - When the harness supports `self_managed` but `isCloud` is true: the
+ "Use subscription" option renders disabled with a tooltip, and the info card (when
+ somehow active) shows the "Not on cloud" badge. The existing auto-reset effect in
+ `useModelHarness` (lines ~292-296) already snaps an illegal mode back.
+
+- **"Use API key" pane** (mode `agenta`): the prototype's two-pane card.
+ - Outer card: 1px `--ag-colorBorderSecondary`, radius 10, min-height ~236px.
+ - **Left rail (190px, `--ag-colorFillQuaternary` bg)**: one row per standard provider
+ from `standardSecretsAtom` (catalog order), then the existing custom providers from
+ `customSecretsAtom`, then "+ Custom provider" pinned at the bottom behind a top
+ border. Row = 22px logo tile + name. Logos: `getProviderIcon` / `LLMIconMap` from
+ `@agenta/ui` (`SelectLLMProvider/utils.ts`, `LLMIcons`) — reuse them instead of the
+ prototype's colored-initial tiles where an icon exists; fall back to an initial tile.
+ Selected row uses the same recipe as SectionRail (filled pill + 600 + primary text).
+ A dot or check may mark providers that already have a key (nice-to-have).
+ Selection state is local (`useState`), initialized from `selectedProviderFamily`
+ and re-synced when the model pick changes it (auto-highlight).
+ - **Right pane** for the selected standard provider: an evolution of the existing
+ `ProviderKeyField` (same immediate-save semantics via
+ `useVaultSecret.handleModifyVaultSecret`, which keeps `providerKeySetupDoneAtom`
+ working): provider heading, subtitle, "API key *" label, monospace `sk-…` password
+ input, Save/Replace button, masked "Key configured" state when a key exists,
+ "Encrypted in transit and at rest." footnote.
+ - **Named connection select**: when the vault has custom-provider connections matching
+ the selected provider (`namedConnectionOptions`), render the existing "Connection"
+ select (Project default + named options) below the key form. This is the control
+ that moves here from Advanced (D4, default: keep it).
+ - **Custom provider inline**: clicking "+ Custom provider" (or an existing custom
+ entry) swaps the right pane's content to the extracted `CustomProviderForm`
+ (section 4). Same card, same dimensions, no drawer, no modal. The form gets a
+ Cancel that swaps back to the key pane and a Save that writes the vault immediately
+ (as the old drawer did) then swaps back with the new entry selected.
+
+- **"Use subscription" pane** (mode `self_managed`): the rail and form disappear; one
+ info card replaces the card's whole content (icon tile, heading "Self-managed", body
+ copy, self-hosting guide link, "Not on cloud" badge when `isCloud`). Copy in section 7.
+
+### 3.3 What feeds `selectedProviderFamily`
+
+In `useModelHarness`:
+`providerForModel(capabilities, harnessValue, modelId) ?? connection.provider` — the
+same expression `providerVaultEntry` uses today (lines ~188-202). Extract it to a
+variable and pass it down.
+
+## 4. Custom-provider form extraction
+
+Goal: one form component shared by the old `ConfigureProviderDrawer` (model-registry
+page and the model-picker "Add provider" footer) and the new inline pane. Reusability is
+an explicit owner requirement.
+
+New home: `web/packages/agenta-entity-ui/src/secretProvider/CustomProviderForm.tsx`
+(entity-specific UI → `@agenta/entity-ui` per the package placement rules; exported from
+the package index).
+
+Moves required by the layering rules (`@agenta/entity-ui` cannot import `web/oss`):
+
+| What | From | To |
+| --- | --- | --- |
+| `PROVIDER_FIELDS`, `PROVIDER_AUTH_REQUIREMENTS` | `web/oss/src/components/ModelRegistry/Drawers/ConfigureProviderDrawer/assets/constants.ts` | `@agenta/entities/secret/core` (pure data about the secret entity) |
+| `isSlugInputValid` | `web/oss/src/lib/helpers/utils.ts` | `@agenta/shared/utils` (pure string helper); keep a re-export in the old location |
+| `LabelInput` | `web/oss/src/components/ModelRegistry/assets/LabelInput/` | `@agenta/ui` (tiny presentational input), or replace with the plain label+Input pattern the form already uses for textarea/json fields |
+| `ModelNameInput` | `.../ConfigureProviderDrawer/assets/ModelNameInput.tsx` | moves with the form into `@agenta/entity-ui/secretProvider/` |
+
+Component contract:
+
+```ts
+interface CustomProviderFormProps {
+ // data: the entity being edited (null = create)
+ initialValue?: LlmProvider | null
+ // protocol context: how the host embeds it
+ layout?: "drawer" | "inline" // spacing only; logic identical
+ // lifecycle callbacks
+ onSaved: (saved: LlmProvider) => void
+ onCancel: () => void
+ disabled?: boolean
+}
+```
+
+The form keeps its own antd `Form` instance, validation (slug rules, either/or auth
+sets, JSON fields), and submit via `useVaultSecret.handleModifyCustomVaultSecret` —
+moved verbatim from `ConfigureProviderDrawerContent`. The old drawer shell
+(`ConfigureProviderDrawer/index.tsx`) becomes a thin wrapper: `EnhancedDrawer` +
+footer buttons that call the form's submit/cancel (expose them via a `ref` or lift the
+`Form` instance up, matching how the drawer already owns `form`). No behavior change on
+the model-registry page.
+
+`ConfigureProviderModal` (standard keys) is untouched.
+
+## 5. Unsaved-changes guard
+
+Files: `SectionDrawer.tsx` (+ `AgentTemplateControl.tsx` for wiring).
+
+- `SectionDrawer` gains a `dirty?: boolean` prop. `AgentTemplateControl` passes
+ `sectionDirty` (it already computes it) to BOTH section drawers (model-harness and
+ advanced get the guard for free).
+- Inside `SectionDrawer`, the antd `onClose` (mask click, header X, Escape) routes
+ through a handler: if `!dirty`, call `onCancel` as today; if `dirty`, open a confirm
+ modal instead of closing.
+- Modal: `EnhancedModal` from `@agenta/ui` (package rule: never raw antd `Modal`).
+ Three actions:
+ - **Keep editing** (default/cancel): close the modal only.
+ - **Discard**: `onCancel()` (drops the draft, closes the drawer).
+ - **Save changes** (primary): `onSave()` (relays the draft, closes the drawer).
+- The footer **Cancel button stays an immediate discard** (the owner scoped the guard to
+ closes "without Save/Cancel"). Save disabled-state logic is unchanged.
+- Copy in section 7.
+
+## 6. Advanced tab removal
+
+In `useModelHarness.tsx`:
+
+- Delete `authControls`, `authDescription`, `authConnectionField` (lines ~652-701) and
+ the "Authentication" `ConfigAccordionSection` block inside `advancedControls`
+ (lines ~725-745).
+- `hasAdvanced` (lines ~360-368): drop `props.llm` from the condition.
+- `advancedSummary` (lines ~704-710): drop the mode segment; keep the sandbox segment.
+- KEEP: the mode auto-reset effect (lines ~292-296), `connectionOptions`
+ (`namedConnectionOptions`, lines ~299-302; now feeding the credentials pane), and
+ everything the mode still touches in `writeModel`. The mode continues to live at
+ `config.llm.connection.mode`, so the creation-prefs capture in
+ `AgentTemplateControl.saveSection` and the backend contract are untouched.
+- The commit-diff classifier already groups `llm` changes under model-harness, so the
+ section draft-dots keep working.
+
+## 7. Copy strings (final wording)
+
+| Where | String |
+| --- | --- |
+| Toggle option A | `Use API key` |
+| Toggle option B | `Use subscription` |
+| Toggle B disabled tooltip (cloud) | `Available on self-hosted Agenta only.` |
+| Standard provider subtitle | `Standard provider · add your key and we auto-list its models.` |
+| Key label | `API key *` |
+| Key placeholder | `sk-…` |
+| Key footnote | `This secret is encrypted in transit and at rest.` |
+| Key configured state | `Key configured · enter a new value to replace it.` (existing) |
+| Connection select label | `Connection` |
+| Connection default option | `Project default` |
+| Custom provider rail row | `Custom provider` (with a plus icon) |
+| Custom pane hint | `Fields change per type — Bedrock needs a name, region and access keys.` |
+| Self-managed card heading | `Self-managed` |
+| Self-managed card body | `The harness signs itself in. Use your Claude Code or Codex subscription, or any credentials the harness reads from its own environment, such as environment variables. Agenta stores and injects no key. Requires a self-hosted Agenta deployment.` |
+| Self-managed guide link | `Read the self-hosting guide →` (target: `https://docs.agenta.ai/self-host/quick-start`) |
+| Cloud badge | `Not on cloud` |
+| Unsaved modal title | `You have unsaved changes` |
+| Unsaved modal body | `Save your changes to this agent draft, or discard them?` |
+| Unsaved modal buttons | `Save changes` (primary) / `Discard` / `Keep editing` |
+
+The same "self-managed" framing (harness signs itself in; subscriptions are one case;
+self-hosting required) must replace subscription-only wording in:
+
+- the harness capability descriptions the drawer renders (the harness detail panel's
+ hosting line is data-driven and stays; check the mode descriptions that used to live
+ in `authDescription`),
+- `sdks/python/agenta/sdk/agents/capabilities.py` docstrings if they say
+ "subscription" where they mean self-managed (docs-only, no wire change),
+- any docs page this project touches (run keep-docs-in-sync in the implementation).
+
+## 8. Deployment (cloud) gating seam (D6)
+
+The package needs one boolean: "is this deployment Agenta cloud?". Recommended: extend
+`DrillInUIContext` (the existing app→package bridge, research.md §6) with
+
+```ts
+deployment?: {
+ isCloud: boolean // policy: gates self_managed connections
+ selfHostingGuideUrl?: string // metadata: link target for the info card
+}
+```
+
+wired in `OSSdrillInUIProvider.tsx` from `isDemo()`. Classified by role: `isCloud` is
+deployment policy (owned by the host app, changes never at runtime), not agent config,
+so it must not live in the config draft or the schema. Alternative: an atom in
+`@agenta/shared/state` hydrated by the app layer; the context is preferred because the
+drawer already consumes `useDrillInUI` and the value is UI-gating only.
+
+Gating matrix for "Use subscription":
+
+| `modeOptions` includes `self_managed` | `isCloud` | Result |
+| --- | --- | --- |
+| no | any | toggle hidden (only "Use API key" content, no segmented control) |
+| yes | false | toggle enabled |
+| yes | true | toggle visible, "Use subscription" disabled + tooltip; card (if reached) shows "Not on cloud" badge |
+
+## 9. Interfaces reviewed (design-interfaces pass)
+
+- `ProviderCredentialsSectionProps` (§3.1): fields grouped as config (mode/slug +
+ writers), routing (selectedProviderFamily), policy (modeOptions, isCloud),
+ presentation (disabled). Credentials stay out of the props (they are vault data with
+ their own lifecycle, owned by the secret entity, saved immediately).
+- `CustomProviderFormProps` (§4): data (initialValue), protocol context (layout),
+ lifecycle callbacks. The form owns validation; hosts own chrome.
+- `SectionDrawer.dirty` (§5): presentation-adjacent policy flag; the host owns the
+ draft, so the host computes dirtiness. The drawer only decides whether closing needs
+ a confirmation.
+- No wire, API, or schema contract changes anywhere in this project. The agent config
+ shape (`config.llm.connection.mode/slug`) is untouched.
diff --git a/docs/design/connect-model-drawer/plan.md b/docs/design/connect-model-drawer/plan.md
new file mode 100644
index 0000000000..c52deb2b69
--- /dev/null
+++ b/docs/design/connect-model-drawer/plan.md
@@ -0,0 +1,264 @@
+# Plan: implementation slices and verification
+
+Slices are sized for one Sonnet subagent each, in dependency order. Every slice ends
+with `pnpm lint-fix` in `web/`, a package build check
+(`pnpm turbo run build --filter=@agenta/entity-ui` when packages changed), and a visual
+pass on the dev stack in light AND dark mode. No slice reverts the uncommitted in-flight
+changes already in these files (see context.md).
+
+## Slice 1 — SectionRail selection restyle (small, independent)
+
+Decisions implemented: D2 (global restyle, all five consumers).
+
+Goal: fix the confirmed root cause of "selection is unreadable" — the active row uses
+`var(--ag-colorPrimaryBg)`, a token the theme generator never emits, so it renders
+transparent. Replace it with the filled-pill recipe the owner picked, in the one shared
+component all five consumers render through.
+
+Files/symbols: `web/packages/agenta-entity-ui/src/drawers/shared/SectionRail.tsx`, the
+active-row class string at line ~72 (`!bg-[var(--ag-colorPrimaryBg)] !font-medium
+!text-[var(--ag-colorPrimary)]` → `!bg-[var(--ag-colorFillSecondary)] !font-semibold
+!text-[var(--ag-colorText)]`).
+
+- Keep inactive/hover styles and `rounded-md` unchanged.
+- Do NOT introduce `--ag-colorPrimaryBg` or touch `palette.ts`; the chosen tokens exist.
+- Verify all five consumers (research.md §4 table) in light and dark: harness list in
+ the Model & harness drawer, Model/Provider-key tabs (until slice 4 removes them),
+ Advanced auth rail (until slice 5 removes it), workflow-reference selector
+ (`WorkflowReferenceSelector.tsx`), trigger run-version field (`RunVersionField.tsx`),
+ commit modal (`EntityCommitContent.tsx`), agent-home templates gallery
+ (`web/oss/.../TemplatesGallery/index.tsx`, imports `SectionRail` from
+ `@agenta/entity-ui`).
+
+Acceptance check: selected rows read as a filled pill in light mode; dark mode unchanged
+in feel; no other SectionRail behavior regresses.
+
+Dependencies: none. Touches only `SectionRail.tsx`; safe to run in parallel with slices
+2 and 3.
+
+## Slice 2 — Unsaved-changes guard (small, independent)
+
+Decisions implemented: locked decision "unsaved-changes confirm on scrim/X close when
+dirty; footer Cancel stays immediate" (context.md, item 4; design.md §5). Not one of
+D1-D6 — this was never open for redesign.
+
+Goal: today `SectionDrawer`'s `onClose` (antd mask click, header X, Escape) calls
+`onCancel` directly, silently discarding the draft. Add a `dirty` prop so a dirty close
+opens a confirm modal instead, while the footer Cancel button keeps discarding
+immediately (unchanged, out of scope of this guard).
+
+Files/symbols: `web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SectionDrawer.tsx`
+(currently `onClose={onCancel}` at line ~43, `closeOnLayoutClick={false}` already set —
+the mask and the header X are the only two leak paths); `AgentTemplateControl.tsx`
+(already computes `sectionDirty`, lines ~239-243; pass it as `dirty={sectionDirty}` to
+both `SectionDrawer` instances at lines ~866 and ~878).
+
+- Add the `dirty` prop and the confirm modal per design.md §5 (`EnhancedModal` from
+ `@agenta/ui` — never raw antd `Modal`; buttons "Save changes" (primary) / "Discard" /
+ "Keep editing").
+- Watch the mounted-while-animating pattern: the drawer stays mounted during the close
+ transition; make sure the modal state resets when the drawer reopens.
+
+Acceptance check: with a dirty draft, scrim click and header X show the modal; "Keep
+editing" only closes the modal; "Discard" calls `onCancel`; "Save changes" calls
+`onSave`; a clean draft still closes silently; the footer Cancel button still discards
+immediately without the modal.
+
+Dependencies: none functionally, but it touches `AgentTemplateControl.tsx`, the same
+file slice 5 touches ("only if summaries shift"). Safe to run in parallel with slices 1
+and 3; do NOT run concurrently with slice 5 — sequence them (this slice first, since it
+has no dependency on slice 4).
+
+## Slice 3 — Extract CustomProviderForm (medium, independent)
+
+Decisions implemented: D5 (package home `@agenta/entity-ui/secretProvider/`), and the
+locked decision that the custom-provider form logic is extracted into a component
+shared with the old drawer (context.md, item 2).
+
+Goal: today the custom-provider form lives entirely inside
+`ConfigureProviderDrawerContent.tsx` in `web/oss`, which `@agenta/entity-ui` cannot
+import (package layering). Extract the form verbatim into the package so slice 4's
+inline pane and the existing model-registry drawer both render the same component, with
+no behavior change to the existing surface.
+
+Files/symbols: new `web/packages/agenta-entity-ui/src/secretProvider/CustomProviderForm.tsx`
+(props: `initialValue?: LlmProvider | null`, `layout?: "drawer" | "inline"`, `onSaved`,
+`onCancel`, `disabled?`; design.md §4) + `ModelNameInput.tsx` (moves with the form).
+Source to move from: `web/oss/src/components/ModelRegistry/Drawers/ConfigureProviderDrawer/assets/ConfigureProviderDrawerContent.tsx`
+(the real form: provider-kind select, per-kind fields, either/or auth validation,
+submit via `useVaultSecret.handleModifyCustomVaultSecret`). Moves required by the
+layering rule:
+ - `PROVIDER_FIELDS`, `PROVIDER_AUTH_REQUIREMENTS` (`.../ConfigureProviderDrawer/assets/constants.ts`)
+ → `@agenta/entities/secret/core`.
+ - `isSlugInputValid` (`web/oss/src/lib/helpers/utils.ts` line ~47) → `@agenta/shared/utils`,
+ with a re-export shim left at the old import site.
+ - `LabelInput` (`web/oss/src/components/ModelRegistry/assets/LabelInput/`) → `@agenta/ui`,
+ or replace with the form's existing plain label+Input pattern.
+- Rewire `web/oss/src/components/ModelRegistry/Drawers/ConfigureProviderDrawer/index.tsx`
+ to a thin shell (`EnhancedDrawer` + footer buttons) around the extracted form.
+- Do NOT touch `web/oss/src/components/ModelRegistry/Modals/ConfigureProviderModal/`
+ (a sibling directory under `ModelRegistry/Modals/`, NOT nested inside
+ `Drawers/ConfigureProviderDrawer/` — research.md §6 mis-locates it one level down).
+ It is a separate standard-key modal and stays untouched.
+- Export the form from the `@agenta/entity-ui` index.
+- Respect the import hierarchy (`shared ← ui ← entities ← entity-ui`); no `web/oss`
+ imports inside packages.
+
+Acceptance check: the model-registry page's add/edit custom provider flow works exactly
+as before (create, edit, either/or auth validation, JSON field validation, model list);
+`ConfigureProviderModal` (standard keys) is unaffected.
+
+Dependencies: none. New files + moves only; no overlap with slices 1 or 2. Safe to run
+in parallel with them. Slice 4 needs this slice done first (it embeds the extracted
+form).
+
+## Slice 4 — ProviderCredentialsSection (large; needs slices 1 and 3)
+
+Decisions implemented: D4 (keep named-connection select, moved into "Use API key"
+pane), D6 (cloud gating via `DrillInUIContext.deployment`), plus the locked toggle
+labels/copy and "key saves stay immediate" decisions (context.md, item 2).
+
+Goal: build the new two-pane "Provider credentials" section — a segmented "Use API
+key" / "Use subscription" toggle, a provider rail + key form + named-connection select
+on the API-key side, and a Self-managed info card on the subscription side, gated by
+the harness's allowed connection modes and (new) a cloud-deployment flag.
+
+Files/symbols: new
+`web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderCredentialsSection.tsx`
+(props per design.md §3.1: `mode`, `connectionSlug`, `onModeChange`,
+`onConnectionSlugChange`, `selectedProviderFamily`, `modeOptions`, `isCloud`,
+`disabled?`); `ProviderKeyField.tsx` (evolves into the right-pane form, or is
+superseded by it — same immediate-save semantics via
+`useVaultSecret.handleModifyVaultSecret`); `useModelHarness.tsx` (extract
+`selectedProviderFamily` from the existing `providerVaultEntry` computation at lines
+~188-202 — the same `providerForModel(capabilities, harnessValue, modelId) ??
+connection.provider` expression — and pass it down; wire the new section into
+`writeModel({mode})` / `writeModel({slug})`, reusing the mode auto-reset effect at
+lines ~292-296 and `connectionOptions` at lines ~299-302 unchanged);
+`web/packages/agenta-ui/src/drill-in/context/DrillInUIContext.tsx` (add
+`deployment?: {isCloud, selfHostingGuideUrl?}` next to the existing
+`llmProviderConfig` field at line ~305); `web/oss/src/components/DrillInView/OSSdrillInUIProvider.tsx`
+(wire `deployment.isCloud` from `isDemo()`, `web/oss/src/lib/helpers/utils.ts` line ~22).
+
+- Build the section per design.md §3: header + `Segmented` toggle (antd, theme-aware —
+ do not hand-roll a navy fill; dark mode's primary flips to yellow), two-pane API-key
+ card (provider rail from `standardSecretsAtom` + `customSecretsAtom`, icons via
+ `getProviderIcon`/`LLMIconMap` from `@agenta/ui`'s `SelectLLMProvider/utils.ts` and
+ `LLMIcons`), key form, named connection select, inline custom form (renders the
+ slice-3 `CustomProviderForm` with `layout="inline"`), self-managed info card, gating
+ matrix (design.md §8 table).
+- Key saves stay immediate through `useVaultSecret`; confirm `providerKeySetupDoneAtom`
+ still gets set on a successful save (it flows through `handleModifyVaultSecret`,
+ which the form must keep calling).
+- Map every color to the tokens in research.md §9.
+
+Acceptance check: in the drawer, "Use API key" shows rail + form; keys save
+immediately and the "Connect key" badge clears; "+ Custom provider" swaps the pane
+inline with no drawer; "Use subscription" shows the Self-managed card; gating follows
+the design.md §8 matrix (no `self_managed` in `modeOptions` → toggle hidden; cloud +
+`self_managed` allowed → toggle visible but disabled with tooltip).
+
+Dependencies: needs slice 1 (reuses the SectionRail selection recipe for the provider
+rail) and slice 3 (embeds `CustomProviderForm`). Touches `useModelHarness.tsx`, which
+slice 5 also touches — do NOT run slice 5 concurrently with this slice.
+
+## Slice 5 — Drawer restructure + Advanced removal (medium; needs slice 4)
+
+Decisions implemented: D1 (section order Harness → Model → Provider credentials), plus
+the locked decision "mode selection moves out of Advanced completely" (context.md,
+item 2).
+
+Goal: reorder the Model & harness drawer into three independent sections (Harness,
+Model, Provider credentials — replacing the old two-tab "Model & credentials" combo
+section built in slice 4), and delete the Advanced drawer's entire Authentication
+group now that the credentials section owns the mode UI.
+
+Files/symbols: `useModelHarness.tsx` (mostly) — delete `modelTab` state and its
+auto-forcing effect (currently lines ~217-220); split `modelControl` into its own
+`ConfigAccordionSection` ("Model"); render the slice-4 `ProviderCredentialsSection` as
+the third section, passing it `selectedProviderFamily` (design.md §3.3); delete
+`authControls`, `authDescription`, `authConnectionField` (currently lines ~652-701) and
+the "Authentication" `ConfigAccordionSection` block inside `advancedControls`
+(currently lines ~725-745); `hasAdvanced` (currently lines ~355-363) drops the
+`props.llm` condition; `advancedSummary` (currently lines ~704-710) drops the mode
+segment, keeps the sandbox segment. KEEP the mode auto-reset effect (lines ~292-296)
+and `connectionOptions`/`namedConnectionOptions` (lines ~299-302) — they now feed the
+credentials section instead of Advanced. `AgentTemplateControl.tsx` only if the section
+summaries it reads change shape.
+Note: exact line numbers drift by a few lines run-to-run in this file (GitButler's
+background WIP autocommit touches it during editing sessions) — search by symbol name,
+treat cited numbers as approximate.
+
+- Update the tabs-layout `modelHarnessInline` / `advancedInline` bodies to match (they
+ render the same shared controls, so they inherit the restructure automatically).
+- Do not touch the creation-prefs capture in `AgentTemplateControl.saveSection` (reads
+ `connectionFromConfig(draftConfig.llm)`); the mode still lives at
+ `config.llm.connection.mode`, so it needs no change.
+
+Acceptance check: Advanced shows no mode UI anywhere (drawer, inline tab); the
+model-harness drawer shows Harness → Model → Provider credentials in order; saving the
+drawer still records creation prefs (verify `agentCreationPrefsAtom` in localStorage
+after a save that changes the mode).
+
+Dependencies: needs slice 4 (renders `ProviderCredentialsSection`). Touches
+`useModelHarness.tsx`, which slice 4 also touches — run these two sequentially, not in
+parallel. Also touches `AgentTemplateControl.tsx` if summaries shift, the same file
+slice 2 touches — do not run concurrently with slice 2 either.
+
+## Slice 6 — Copy sweep + docs sync (small; needs slices 4 AND 5)
+
+Decisions implemented: the locked "copy alignment" decision (context.md, item 5) — the
+self-managed framing must replace subscription-only wording everywhere it appears.
+
+Goal: apply the final copy strings and sweep stale "subscription"-only wording to the
+"self-managed" framing (harness signs itself in; a subscription is one case; requires
+self-hosting) everywhere it appears, not just in the new component.
+
+Files/symbols: the new `ProviderCredentialsSection.tsx` (apply the design.md §7 copy
+table: toggle labels, key form labels/footnote, self-managed card copy, unsaved-modal
+copy); the harness capability descriptions the drawer renders; `sdks/python/agenta/sdk/agents/capabilities.py`
+docstrings if they say "subscription" where they mean self-managed (docs-only, no wire
+change); any docs pages this project touches (run the `keep-docs-in-sync` skill).
+
+- Depends on BOTH slice 4 (introduces the new copy strings) and slice 5 (deletes the
+ old Advanced `authDescription`/`authControls` wording this project would otherwise
+ need to sweep and then immediately discard).
+- Writing style: no em dashes, active voice, short sentences.
+
+Acceptance check: no drawer surface uses "Agenta-managed"/"subscription-only" wording
+where "self-managed" is meant; `capabilities.py` docstrings and touched docs pages match;
+`keep-docs-in-sync` reports no drift.
+
+Dependencies: needs slices 4 and 5 both done. Last slice; nothing depends on it.
+
+## Verification plan (after slice 5, again after slice 6)
+
+Environment: dev stack `http://144.76.237.122:8280` (EE dev). Use the
+debug-local-deployment skill for login and logs. Run every check in light AND dark.
+
+1. **Rail styling**: open each SectionRail consumer; selected rows show the filled pill;
+ hover states work; disabled rails look right.
+2. **Drawer flow (happy path)**: new agent → open Model & harness → pick harness →
+ pick model → provider auto-highlights → enter API key → Save (immediate) → key
+ configured state → footer Save → section summary updates → chat gate clears.
+3. **Custom provider inline**: + Custom provider → Bedrock form inline (no drawer) →
+ save → entry appears in the rail and its models in the model picker. Old surface:
+ model-registry page add/edit still works.
+4. **Subscription mode**: harness with `self_managed` in `connection_modes` → toggle
+ appears → switch → card shows Self-managed copy → footer Save → Advanced shows no
+ mode UI → reopen drawer, mode persisted. On a cloud-flagged deployment the option is
+ disabled with the tooltip.
+5. **Gating**: harness without `self_managed` → no toggle. Switching to such a harness
+ with mode `self_managed` set → auto-reset to `agenta` still fires.
+6. **Unsaved guard**: dirty draft + scrim click → modal; Keep editing keeps the drawer;
+ Discard drops the draft; Save changes applies it. Clean draft closes silently.
+ Footer Cancel discards without the modal. Advanced drawer gets the same guard.
+7. **Seams**: creation prefs (`agentCreationPrefsAtom` in localStorage) capture
+ harness/model/provider/connectionMode on save; `providerKeySetupDoneAtom` set after
+ a key save; the chat banner's remote-open still lands on the drawer.
+8. **Regression**: tabs layout (`agentTemplateLayoutAtom`) renders the inline bodies
+ without drawer chrome; the no-capabilities fallback branch (older agents) still
+ renders the flat harness select + model picker + key field.
+9. **Package checks**: `pnpm turbo run build --filter=@agenta/entity-ui --filter=@agenta/entities --filter=@agenta/ui --filter=@agenta/shared`,
+ `pnpm lint-fix`, and the package unit tests
+ (`connectionUtils` tests must still pass; add cases only if helpers changed).
diff --git a/docs/design/connect-model-drawer/research.md b/docs/design/connect-model-drawer/research.md
new file mode 100644
index 0000000000..6ca38aaad2
--- /dev/null
+++ b/docs/design/connect-model-drawer/research.md
@@ -0,0 +1,255 @@
+# Research: current code, verified findings, and the design prototype
+
+All paths are repo-relative. Line numbers reflect the working tree on 2026-07-06 (which
+includes uncommitted in-flight changes; see context.md).
+
+## 1. The drawer host: AgentTemplateControl
+
+`web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx`
+
+- Renders the agent config as sections (Model & harness, Instructions, Tools, MCP,
+ Skills, Triggers, Advanced). The "Model & harness" and "Advanced" sections open a
+ `SectionDrawer` each (lines ~866-888).
+- **Draft model**: `openSectionDrawer` (lines ~182-194) snapshots the config and the
+ build-kit flag into local state (`draftConfig`, `draftBuildKit`) plus a baseline ref.
+ `saveSection` (lines ~211-237) relays the draft via `onChange(draftConfig)` →
+ `workflowMolecule.actions.updateConfiguration`. This is a LOCAL draft only; committing
+ to the server is the playground's separate Commit button. `cancelSection` just drops
+ the draft. `sectionDirty` (lines ~239-243) deep-compares draft vs baseline and gates
+ the Save button.
+- **Creation-prefs capture seam** (uncommitted, lines ~216-230): on a model-harness
+ save, it reads `modelIdFromConfig(draftConfig.llm)` and
+ `connectionFromConfig(draftConfig.llm)` and writes `agentCreationPrefsAtom`
+ (`web/packages/agenta-entities/src/workflow/state/agentCreationPrefs.ts`, consumed by
+ `createEphemeralAppFromTemplate` in `state/appUtils.ts` line ~189). The capture reads
+ the mode from the llm object, not from any section's UI, so moving the mode control
+ into the credentials section keeps it working untouched.
+- **Remote open**: `openAgentConfigSectionAtom`
+ (`web/packages/agenta-shared/src/state/openConfigSection.ts`) lets the chat banner
+ open the "model-harness" drawer (lines ~203-208).
+- **Two `useModelHarness` instances** (lines ~278-292): `mh` binds to the live entity
+ (headers, badges, inline tab bodies); `mhDraft` binds to the draft and renders the open
+ drawer's body. Redesigned bodies automatically inherit this split.
+- Section badge tri-state (lines ~451-493): invalid ("No model" / "Unavailable") beats
+ incomplete ("Connect key") beats draft dot.
+
+## 2. The drawer chrome: SectionDrawer and EnhancedDrawer
+
+`web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SectionDrawer.tsx`
+
+- Pure chrome over `EnhancedDrawer` (`web/packages/agenta-ui/src/drawer/EnhancedDrawer.tsx`).
+- Passes `onClose={onCancel}`. Every close path other than the footer buttons goes
+ through antd's `onClose`: **the mask (scrim) click and the header X both call
+ `onCancel`, which silently discards the draft.** This is the interception point for the
+ unsaved-changes guard.
+- `closeOnLayoutClick={false}` already disables EnhancedDrawer's extra
+ click-outside-on-layout listener, so only the mask and the X are the leak paths.
+- Footer: note text + Cancel + Save (disabled unless dirty).
+
+## 3. The stateful core: useModelHarness
+
+`web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx`
+
+One hook that returns summaries and bodies for both the Model & harness and Advanced
+sections. Key regions:
+
+- **Harness catalog**: capabilities come from the `harnesses` catalog
+ (`GET /workflows/catalog/harnesses/`) via `harnessCapabilitiesAtomFamily`
+ (`web/packages/agenta-entities/src/workflow/state/inspectMeta.ts`, `staleTime:
+ Infinity`), keyed by the schema's `x-ag-harness-ref` (lines ~153-165). Shape per
+ harness: `{providers, deployments?, connection_modes, model_selection, models}`.
+ There is NO hosting/cloud field in the FE type today.
+- **Connection mode**: lives at `config.llm.connection.mode` (`"agenta" |
+ "self_managed"`; helpers in `../connectionUtils.ts`). `modeOptions =
+ allowedConnectionModes(capabilities, harnessValue)` (lines ~176-179,
+ connectionUtils.ts lines ~191-200: missing capabilities → both modes). An effect
+ auto-resets a mode the harness disallows (lines ~292-296). These stay valid wherever
+ the mode UI renders.
+- **providerNeedsKey** (lines ~188-212, includes the uncommitted
+ `mode !== "self_managed"` guard): resolves the selected model's provider family
+ (`providerForModel`, falls back to `connection.provider`), finds its standard vault
+ entry, and only asserts "needs key" after the vault query resolves.
+- **Model & harness drawer body** (lines ~536-643): two `ConfigAccordionSection`s.
+ "Harness" holds an info note plus `harnessSection` (lines ~469-530), a `SectionRail`
+ of harnesses with a detail panel (Current pill, model-compat line, providers, hosting).
+ "Model & credentials" (lines ~559-612) holds a `SectionRail` with two tabs, "Model"
+ (the picker) and "Provider key" (`ProviderKeyField`), with warning dots. Right side:
+ a 240px version-history skeleton. Drawer width 880 with capabilities, 560 without
+ (line ~886).
+- **modelTab auto-forcing** (lines ~217-220): lands on "key" when a key is missing.
+ Goes away with the tabs (the credentials section is always visible).
+- **Advanced body**: `authControls` (lines ~677-701) renders the mode `SectionRail`
+ ("Agenta-managed" / "Self-managed") plus, in agenta mode, the named-connection
+ `Select` (options from `namedConnectionOptions`). It sits inside an "Authentication"
+ `ConfigAccordionSection` (lines ~725-745). `hasAdvanced` counts `props.llm` (lines
+ ~360-368, with the comment "Authentication lives in Advanced now") and
+ `advancedSummary` leads with the mode (lines ~704-710). All of this moves or dies in
+ the redesign.
+- **writeModel** (lines ~239-284): composes `config.llm` from patches; a model pick
+ derives its provider (and a vault pick its connection slug). The credentials section
+ will call `writeModel({mode})` and the model section `writeModel({modelId})` exactly
+ as today.
+
+## 4. The shared rail: SectionRail (and the confirmed styling bug)
+
+`web/packages/agenta-entity-ui/src/drawers/shared/SectionRail.tsx`
+
+Active row style (line ~72):
+`!bg-[var(--ag-colorPrimaryBg)] !font-medium !text-[var(--ag-colorPrimary)]`.
+
+**Confirmed root cause of "selection is just bold text":** the theme generator never
+emits `--ag-colorPrimaryBg` (zero matches in `web/oss/src/styles/theme-variables.css`).
+The CSS variable resolves to nothing, `background:` gets an invalid value, and the row
+falls back to transparent. Only the font-weight change and the primary text color
+(#1c2c3d in light mode, nearly the same as the default text color) remain, hence
+"unreadable".
+
+**Blast radius (every SectionRail consumer):**
+
+| Consumer | File | Rails |
+| --- | --- | --- |
+| Model & harness drawer | `.../agentTemplate/useModelHarness.tsx` | harness list; Model/Provider-key tabs; auth mode (Advanced) |
+| Workflow reference selector | `.../SchemaControls/WorkflowReferenceSelector.tsx` (lines ~524, ~573) | detail-section rails |
+| Trigger run-version field | `.../gatewayTrigger/drawers/shared/RunVersionField.tsx` (line ~104) | Pinned/Deployed axis |
+| Commit modal | `.../modals/commit/components/EntityCommitContent.tsx` (line ~394) | section rail |
+| Agent-home templates gallery | `web/oss/src/components/pages/agent-home/components/TemplatesGallery/index.tsx` (line ~155, imported from `@agenta/entity-ui`) | template category rail |
+
+All five render the same semantic thing (a selected item in a vertical rail) and all
+five are equally broken today, which argues for a global restyle rather than a variant
+prop. Flagged as a decision in status.md.
+
+`RailField.tsx` (same folder) only mimics the rail layout for labels; it has no
+selection state and is unaffected.
+
+## 5. Secrets and providers
+
+`web/packages/agenta-entities/src/secret/state/atoms.ts`:
+
+- `vaultSecretsQueryAtom` — `GET /secrets/`, query key `["vault","secrets",userId,projectId]`.
+- `standardSecretsAtom` — maps the static `llmAvailableProviders` catalog
+ (`web/packages/agenta-shared/src/utils/llmProviders.ts`: 13 providers, each
+ `{title, name: ENV_KEY, key}`) onto vault data, attaching `key`/`id` when stored.
+ This is the provider rail's data source for standard providers.
+- `customSecretsAtom` — vault entries with `type === "custom_provider"`; the rail's
+ custom entries.
+- `createStandardSecretAtom` — create-or-update a standard provider key (POST/PUT
+ `/secrets/`).
+- `providerKeySetupDoneAtom` (uncommitted, `getOnInit: true`) — persisted flag set on a
+ successful key save; feeds the chat gate. The new key form must keep setting it
+ (it flows through `useVaultSecret.handleModifyVaultSecret`, which is what
+ `ProviderKeyField` calls today; verify the flag set stays on that path).
+- `useVaultSecret` (`state/useVaultSecret.ts`) — the hook both existing key surfaces
+ use: `handleModifyVaultSecret` (standard) and `handleModifyCustomVaultSecret` (custom).
+
+`ProviderKeyField.tsx` (`.../agentTemplate/`): the existing immediate-save key field
+(disabled env-name input + password input + Save/Replace button + "Encrypted in transit
+and at rest" footnote). The new right-pane key form is an evolution of this component.
+
+## 6. The custom-provider form (extraction target)
+
+`web/oss/src/components/ModelRegistry/Drawers/ConfigureProviderDrawer/`:
+
+- `index.tsx` — thin antd-Form drawer shell (480px) around the content.
+- `assets/ConfigureProviderDrawerContent.tsx` — the real form: provider-kind select
+ (`SelectLLMProviderBase`), per-kind fields from `PROVIDER_FIELDS`, either/or auth-set
+ validation from `PROVIDER_AUTH_REQUIREMENTS` (both in `assets/constants.ts`), slug
+ validation via `isSlugInputValid` from `@/oss/lib/helpers/utils`, model list via
+ `ModelNameInput`, label inputs via `../../assets/LabelInput`, submit through
+ `useVaultSecret.handleModifyCustomVaultSecret`.
+- `Modals/ConfigureProviderModal/` — a separate standard-key modal (title + key input);
+ NOT the custom form. It stays as is unless it later adopts the shared key field.
+
+**Layering constraint:** the drawer pane lives in `@agenta/entity-ui`, which cannot
+import from `web/oss`. Extracting the form means moving it (and its constants) into a
+package and moving or replacing its two app-layer imports (`isSlugInputValid`,
+`LabelInput`). Details in design.md.
+
+**Existing bridge for app-layer content:** `DrillInUIContext.llmProviderConfig`
+(`web/packages/agenta-ui/src/drill-in/context/DrillInUIContext.tsx` line ~305), wired by
+`web/oss/src/hooks/useLLMProviderConfig.tsx` through `OSSdrillInUIProvider.tsx`. Today it
+injects extra model option groups and an "Add provider" footer that opens
+`ConfigureProviderDrawer`. This is the natural seam to extend for deployment (cloud)
+gating.
+
+## 7. Cloud detection
+
+The app layer has `isDemo()` (= `isEE()`) in `web/oss/src/lib/helpers/utils.ts` line ~22
+and `NEXT_PUBLIC_AGENTA_LICENSE` in `web/packages/agenta-shared/src/api/env.ts`. Neither
+distinguishes "Agenta cloud" from a self-hosted EE install by itself; the cloud check
+used elsewhere in the app is `isDemo()`. The package cannot call it directly, so the
+plan passes an `isCloud` flag through `DrillInUIContext` (see design.md, decision D6).
+
+## 8. The design prototype, extracted
+
+`Agenta onboarding flow redesign (1)/Connect a Model Flow.dc.html` (28 KB, self-contained).
+
+Structure: right drawer 640px over a scrim `rgba(5,23,41,0.45)`; header "Connect a
+model" with an X; scrollable body with three stacked sections separated by 1px `#eaeff5`
+rules (Harness, Provider credentials, Model); sticky footer with a hint
+("Sets Pi · claude-sonnet-4-5 on this agent draft") + Cancel + Save (`#1c2c3d` fill).
+
+Recipes we adopt (with the exact prototype values):
+
+- **Selected list row**: `background:#eef1f5; font-weight:600; color:#1c2c3d;
+ border-radius:7px`; unselected `color:#586673`; row hover `background:#f7f9fb`.
+- **Segmented toggle**: 1px `#d6dee6` container, radius 8, active segment white text on
+ `#1c2c3d`, inactive `#586673`, `font-size:13px; padding:8px 14px`.
+- **Provider credentials two-pane**: outer 1px `#eaeff5` card, radius 10, min-height
+ 236px. Left rail 190px, `background:#fcfdfe`, right border `#eaeff5`, padding 8,
+ rows: 22px rounded-6 colored logo tile (white initial on a per-provider color) + name,
+ 13.5px. "+ Custom provider" pinned bottom behind a top border, plus icon,
+ `font-weight:500; color:#1c2c3d` idle.
+- **Key form** (right pane, standard provider): provider name 14.5px/600; subtitle
+ "Standard provider · add your key and we auto-list its models." 12px `#758391`;
+ label "API key *" (asterisk `#d61010`); input `sk-…` placeholder, monospace
+ (`var(--font-mono)`), 1px `#d6dee6`, radius 8; footnote "This secret will be encrypted
+ in transit and at rest." 11.5px `#97a4b0`.
+- **Custom provider pane**: heading "Custom provider"; Type select with chevron; per-type
+ fields ("Fields change per type — Bedrock needs a name, region & access keys.").
+- **Self-managed card**: 38px icon tile (`#f5f7fa` bg, `#eaeff5` border, radius 10);
+ heading 14.5px/600; body 12.5px `#586673`; bordered pill link "Read the self-hosting
+ guide →"; amber badge "Not on cloud" (`#8a6d00` on `#fffbe6`, border `#ffe58f`).
+ Note: the prototype heading says "Use your own subscription" and the toggle says
+ "Agenta-managed / Self-managed"; the owner OVERRODE both (toggle "Use API key" /
+ "Use subscription"; card heading "Self-managed"). The owner's copy wins.
+- **Harness dropdown + connection status chips**: present in the prototype but OUT of
+ scope (harness layout is locked; the chat banner is done).
+
+## 9. Design hex → theme token mapping
+
+Palette source: `web/oss/src/styles/theme/palette.ts`; generated vars in
+`web/oss/src/styles/theme-variables.css`. Relevant existing values:
+
+| Prototype hex | Role in prototype | Token to use | Light value today | Dark value today |
+| --- | --- | --- | --- | --- |
+| `#1c2c3d` | primary text, dark-navy fills | text: `--ag-colorText`; fills: see note | `#1c2c3d` | `rgba(255,255,255,0.85)` |
+| `#586673` | secondary text | `--ag-colorTextSecondary` | `#586673` | `rgba(255,255,255,0.65)` |
+| `#758391` / `#97a4b0` | tertiary/hint text | `--ag-colorTextTertiary` / `--ag-colorTextQuaternary` | — | — |
+| `#eef1f5` | selected row bg | `--ag-colorFillSecondary` | `rgba(5,23,41,0.06)` | `rgba(255,255,255,0.12)` |
+| `#f7f9fb` | row hover bg | `--ag-colorFillTertiary` | `rgba(5,23,41,0.04)` | `rgba(255,255,255,0.08)` |
+| `#eaeff5` | hairline borders, card borders | `--ag-colorBorderSecondary` | `#eaeff5` | `#303030` |
+| `#d6dee6` | input/control borders | `--ag-colorBorder` | — | — |
+| `#fcfdfe` | provider rail bg | `--ag-colorFillQuaternary` (or `--ag-colorBgLayout`) | — | — |
+| `#fffbe6` / `#ffe58f` / `#8a6d00` | amber badge | `--ag-colorWarningBg` / `--ag-colorWarningBorder` / `--ag-colorWarningText` | `#ffe58f` border confirmed in palette | dark pair generated |
+| radius 7/8px | rows, inputs | keep Tailwind `rounded-md`/`rounded-lg` (6/8px), close enough; no new token |
+| `var(--font-mono)` | key input | Tailwind `font-mono` |
+
+Notes:
+
+- `rgba(5,23,41,0.06)` renders visually identical to `#eef1f5` on white, so
+ `--ag-colorFillSecondary` needs no palette change. **No `palette.ts` edit is required
+ for the rail restyle**; if the owner wants the exact flat hex, add a role and
+ regenerate (never hand-edit generated files).
+- **Dark-navy fills** (`#1c2c3d` active segment / Save button): do NOT hardcode. In dark
+ mode the brand primary flips to yellow (`palette.ts` line ~79: `primary: {light:
+ "#1c2c3d", dark: "#f2f25c"}`), so hand-rolling white-on-navy breaks dark mode. Use the
+ antd `Segmented` component (theme-aware) or `--ag-colorPrimary` +
+ `--ag-colorTextLightSolid`-equivalent tokens for the active segment.
+- The broken `--ag-colorPrimaryBg` reference must not simply be "fixed" by generating
+ that token: the owner picked the neutral filled-pill recipe, not a primary-tinted one.
+
+## 10. Verification environment
+
+Dev stack on the Hetzner box: `http://144.76.237.122:8280` (EE dev, hot-reloads web
+changes; package changes under `web/packages` are mounted). Light and dark themes both
+required. The `debug-local-deployment` skill documents login and log access.
diff --git a/docs/design/connect-model-drawer/status.md b/docs/design/connect-model-drawer/status.md
new file mode 100644
index 0000000000..01810784fa
--- /dev/null
+++ b/docs/design/connect-model-drawer/status.md
@@ -0,0 +1,131 @@
+# Status
+
+**Phase: slices 1-6 implemented 2026-07-06. PR #5096 is the feature PR. Pending review
+and live-stack verification (plan.md's verification plan, light + dark).**
+
+Last updated: 2026-07-06 (slice 6 — copy sweep + docs sync — landed, completing the
+plan. All six slices are in the working tree: SectionRail restyle, the unsaved-changes
+guard, the extracted `CustomProviderForm`, `ProviderCredentialsSection`, the drawer
+restructure with Advanced auth removed, and this slice's copy/docs sweep).
+
+## Done
+
+- Codebase research verified against the working tree (research.md). Key finding: the
+ unreadable selection is a missing theme token (`--ag-colorPrimaryBg` is never
+ generated), not a design choice.
+- Design prototype extracted and every hex mapped to existing `--ag-*` tokens; no
+ palette change needed (research.md §8-9).
+- Component design, contracts, copy strings, and gating matrix written (design.md).
+- Implementation sliced for Sonnet subagents with a verification plan (plan.md).
+
+## Decisions D1-D6 (locked, owner answered on PR #5096, 2026-07-06)
+
+All six landed on the plan's recommended default — no alternative was chosen.
+
+- **D1 — Section order: Harness → Model → Provider credentials.** The model pick
+ auto-highlights its provider in the credentials rail below it (design.md §1). The
+ prototype's Harness → Credentials → Model order is NOT used.
+- **D2 — SectionRail restyle scope: GLOBAL.** All five consumers restyle together
+ (harness list, workflow-reference selector, trigger run-version field, commit modal,
+ agent-home templates gallery; research.md §4). No `selectionStyle` variant prop.
+- **D3 — Drawer width and side panel: keep as is.** 880px drawer width and the 240px
+ version-history skeleton stay; the prototype's 640px single-column layout is not
+ adopted.
+- **D4 — Named-connection select: keep it**, moved into the "Use API key" pane below
+ the key form (design.md §3.2). It stays the only UI for named vault connections.
+- **D5 — CustomProviderForm package home: `@agenta/entity-ui` (`secretProvider/`)**,
+ per the plan's recommendation and consistent with the `agenta-package-practices`
+ placement rule (entity-specific UI → `@agenta/entity-ui`). Prerequisite moves:
+ `PROVIDER_FIELDS`/`PROVIDER_AUTH_REQUIREMENTS` → `@agenta/entities/secret/core`,
+ `isSlugInputValid` → `@agenta/shared` (re-export shim left in place),
+ `LabelInput` → `@agenta/ui` or replaced (design.md §4).
+- **D6 — Cloud-gating seam: extend `DrillInUIContext`** with
+ `deployment: {isCloud, selfHostingGuideUrl?}`, wired from `isDemo()` in
+ `OSSdrillInUIProvider.tsx` (design.md §8). No shared-state-atom alternative.
+
+## Other locked decisions (pre-existing, not open for redesign)
+
+- Harness section layout and behavior unchanged; only the selection styling.
+- Toggle labels "Use API key" / "Use subscription"; card heading "Self-managed".
+- Key saves are immediate (vault write on the per-provider Save); the drawer footer
+ Save commits only the agent config draft.
+- Custom-provider form renders inline in the right pane; no nested drawer; the form
+ logic is extracted into a reusable component shared with the old drawer.
+- Mode selection moves out of Advanced completely.
+- Unsaved-changes confirm on scrim/X close when dirty; footer Cancel stays immediate.
+- Out of scope: key validation, other drawer sections, ConnectModelBanner, harness
+ layout changes.
+
+## Blockers
+
+None. The plan builds on uncommitted in-flight changes in the same files (chat gate,
+creation prefs, `providerKeySetupDoneAtom`, self-managed pill fix); implementers must
+not revert them (context.md "Constraints").
+
+## Slice 6 — copy sweep + docs sync (done, 2026-07-06)
+
+- Fixed a real `react-hooks/static-components` eslint error in
+ `ProviderCredentialsSection.tsx`: `ProviderTile` called `getProviderIcon()` inline and
+ rendered the result as ``, which recreates the component every render. Extracted
+ a `renderProviderIcon()` helper (returns a `ReactNode`, not a component — the same
+ pattern already used in `PromptSchemaControl.tsx` / `ToolSelectorPopover.tsx` /
+ `ToolItemControl.tsx`) so the icon lookup happens outside JSX-component position.
+- Copy sweep found the toggle labels, key-form copy, and self-managed card copy in
+ `ProviderCredentialsSection.tsx` / `ProviderKeyField.tsx` already matched design.md §7
+ (slice 4 applied them correctly). Two real leftovers fixed:
+ - `SectionDrawer.tsx`'s unsaved-changes modal still had the pre-design copy — title
+ "Unsaved changes" → "You have unsaved changes"; body "You have unsaved changes in
+ this section." → "Save your changes to this agent draft, or discard them?" (design.md
+ §7 modal copy row). The three button labels already matched.
+ - `useModelHarness.tsx` had one stale "Agenta-managed" comment (on the named-connection
+ options) reworded to "the 'Use API key' mode".
+ - `sdks/python/agenta/sdk/agents/capabilities.py`: broadened the `PI_SUBSCRIPTION_MODELS`
+ comment block, which parenthetically equated `self_managed` with "the subscription
+ OAuth" for `openai-codex`, to state `self_managed` is broader (any way a harness signs
+ itself in without an Agenta-stored key, including env vars) and that this provider's
+ on-ramp happens to be OAuth. Docstrings/prose only; no keys, values, or logic changed.
+ `ruff format` + `ruff check --fix` clean.
+- Docs sync: see the "Docs touched / deferred" section below.
+- Verification for this slice: `npx eslint` clean on the touched TS files, `npx prettier
+ --check` clean, `pnpm --filter @agenta/entity-ui run types:check` clean, `npx vitest run`
+ in `agenta-entity-ui` — 133/133 passing (no test changes needed; no helper logic
+ changed).
+
+## Docs touched / deferred (slice 6)
+
+Searched `docs/docs/` (the public Docusaurus site), `docs/design/agent-workflows/documentation/`,
+and `docs/design/agent-workflows/interfaces/` (the interface inventory) for pages describing the
+drawer's connection-mode UI location, "Agenta-managed"/"Self-managed" toggle wording, or
+subscription-only self-managed phrasing.
+
+- **Touched**: `docs/design/agent-workflows/documentation/agent-configuration.md` (the
+ `AgentConfigControl` "Layer 1" walkthrough, `model` bullet). It described an **Authentication**
+ toggle nested "below the picker" with *Agenta-managed* vs *Self-managed* wording and a
+ subscription-only self-managed description. Rewrote that bullet only: the connection mode now
+ lives in its own **Provider credentials** section (not nested under the model picker), toggle
+ labels are *Use API key* / *Use subscription*, and the self-managed description covers a
+ subscription or any credentials the harness reads from its own environment (env vars), not
+ subscription alone. The `ModelRef`/`model_ref.connection` wire-shape sentences were left
+ unchanged (the data contract didn't change, only the UI).
+- **Not stale, left as-is**:
+ `docs/design/agent-workflows/interfaces/in-service/model-connection-resolution.md` and
+ `docs/design/agent-workflows/interfaces/public-edge/agent-config-schema.md` document the
+ `ModelRef`/`Connection` data contract (`mode: "agenta" | "self_managed"`), which this project
+ did not change — only the UI's structure and copy moved. `docs/docs/` (the public site) has no
+ page documenting this drawer's auth UI at all, so nothing there needed a fix.
+- **Deferred (not fixed, historical record, out of scope for this slice)**:
+ `docs/design/agent-workflows/projects/agent-model-picker/status.md` (lines ~30-46) and
+ `docs/design/agent-workflows/projects/provider-model-auth/explainer.md` (line ~77) still use
+ "Agenta-managed"/old "Provider key" wording. Both are project status/explainer docs recording a
+ point-in-time decision history, not living documentation of current behavior, so they were left
+ untouched rather than rewritten as if the redesign had been true all along. If a future reader
+ finds these confusing, add a one-line "superseded by connect-model-drawer" pointer at the top of
+ each rather than rewriting the historical narrative.
+
+## Next steps
+
+1. Full plan verification on the dev stack (`144.76.237.122:8280`), light and dark, per
+ plan.md's 9-point verification plan (rail styling, drawer happy path, custom-provider
+ inline, subscription mode + cloud gating, gating matrix, unsaved guard, seams,
+ tabs-layout regression, package checks).
+2. Review PR #5096 and address feedback.
diff --git a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx
index e8f529cdb4..9294e88271 100644
--- a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx
+++ b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx
@@ -413,13 +413,11 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId:
const pendingRun = useAtomValue(simulatedAgentRunAtomFamily(entityId))
const setPendingRun = useSetAtom(simulatedAgentRunAtomFamily(entityId))
- // Model connection: does the vault hold a key for this agent's model provider? Drives the
- // connect-a-model banner AND disables the composer until connected. Only gate when the provider
- // is KNOWN but keyless — an unresolved provider must never dead-end the composer with no banner.
+ // Model connection: is the project vault empty (no key of any kind), the agent not self-managed,
+ // and the user never set up a key before? Drives the connect-a-model banner AND disables the
+ // composer until connected — see `gateActive` on `useAgentModelKeyStatus` for the full chain.
const modelKey = useAgentModelKeyStatus(entityId)
- // `!modelKey.loading`: never block until the vault query resolves — otherwise a reload flashes a
- // false "connect a model" gate (the static provider catalog reads keyless until the query lands).
- const modelBlocked = !!modelKey.providerEntry && !modelKey.hasKey && !modelKey.loading
+ const modelBlocked = modelKey.gateActive
// ── Playground-native onboarding ──────────────────────────────────────────
// This chat panel IS the onboarding surface while the agent is ephemeral: the empty state shows the
diff --git a/web/oss/src/components/AgentChatSlice/components/ConnectModelBanner.tsx b/web/oss/src/components/AgentChatSlice/components/ConnectModelBanner.tsx
index 09f4bc1b74..1ea652930a 100644
--- a/web/oss/src/components/AgentChatSlice/components/ConnectModelBanner.tsx
+++ b/web/oss/src/components/AgentChatSlice/components/ConnectModelBanner.tsx
@@ -11,26 +11,26 @@ import {chatPanelMaximizedAtom} from "../state/panelLayout"
import RevealCollapse from "./RevealCollapse"
/**
- * Connect-a-model prompt shown above the composer when the agent's model provider has no vault key.
- * The composer is disabled alongside it (the parent gates on the same status). "Set up credentials"
- * flips the playground to Build and opens the Model & harness drawer, whose bottom credentials field
- * lets the user add the key without leaving — saving it clears this banner reactively (vault → hasKey).
+ * Connect-a-model prompt shown above the composer while the project vault is empty (see `gateActive`
+ * on `useAgentModelKeyStatus` — project-wide, not per-provider). The composer is disabled alongside it
+ * (the parent gates on the same status). "Set up credentials" flips the playground to Build and opens
+ * the Model & harness drawer, whose bottom credentials field lets the user add the key without leaving
+ * — saving it clears this banner reactively (any key added → gate never fires again).
*
- * Always mounted so it can animate IN (status resolves to keyless) and OUT (key added / not applicable)
- * via `RevealCollapse` instead of popping. Shown only when the provider is resolved, keyless, the vault
- * query has landed (never a false gate), and not `suppressed` (the pre-commit onboarding defers the check).
+ * Always mounted so it can animate IN (gate activates) and OUT (key added / not applicable) via
+ * `RevealCollapse` instead of popping. Shown only when `gateActive` and not `suppressed` (the
+ * pre-commit onboarding defers the check).
*/
const ConnectModelBanner = ({
- hasKey,
provider,
providerEntry,
- loading,
+ gateActive,
suppressed = false,
}: AgentModelKeyStatus & {suppressed?: boolean}) => {
const setChatMaximized = useSetAtom(chatPanelMaximizedAtom)
const openConfigSection = useSetAtom(openAgentConfigSectionAtom)
- const open = !suppressed && !loading && !hasKey && !!providerEntry
+ const open = !suppressed && gateActive
// Latch the label so the banner keeps its text while it collapses closed (the leave transition
// needs its content to persist through the height animation).
const labelRef = useRef("a model")
diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentModelKeyStatus.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentModelKeyStatus.ts
index fe380ea247..d34d944318 100644
--- a/web/oss/src/components/AgentChatSlice/hooks/useAgentModelKeyStatus.ts
+++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentModelKeyStatus.ts
@@ -1,8 +1,13 @@
import {useMemo} from "react"
-import {standardSecretsAtom, vaultSecretsQueryAtom} from "@agenta/entities/secret"
+import {
+ providerKeySetupDoneAtom,
+ standardSecretsAtom,
+ vaultSecretsQueryAtom,
+} from "@agenta/entities/secret"
import {workflowMolecule} from "@agenta/entities/workflow"
import type {LlmProvider} from "@agenta/shared/types"
+import {normalizeProviderFamily} from "@agenta/shared/utils"
import {useAtomValue} from "jotai"
export interface AgentModelKeyStatus {
@@ -21,20 +26,28 @@ export interface AgentModelKeyStatus {
* connect banner) while this is true — otherwise the gate flashes a false error on every reload.
*/
loading: boolean
+ /**
+ * The connect-a-model gate: resolved provider, vault loaded, vault holds NO secret of any kind
+ * (project-wide — not just this provider's), the agent isn't self-managed, and the user has never
+ * completed key setup before. Banner and composer-block consumers should both key off this.
+ */
+ gateActive: boolean
}
-/** Strip the `_API_KEY` suffix from a vault env name → provider family ("OPENAI_API_KEY" → "openai"). */
-const providerFromEnvName = (name: string): string => name.toLowerCase().replace(/_api_key$/, "")
-
interface LlmRef {
provider?: unknown
model?: unknown
+ connection?: {mode?: unknown} | null
}
/**
* Model → provider → vault-key detection for an agent. The `agent.llm` value is a structured ModelRef
* carrying its `provider`; we check the project's vault (`standardSecretsAtom`) for a key for that
* provider. Harness (Pi/Claude) is a separate axis and NOT part of this check.
+ *
+ * The connect-model gate (`gateActive`) is project-wide, not per-provider: once the project has ANY
+ * vault secret, or the user has connected a key once before, or the agent is self-managed, the gate
+ * never fires again — "it's not our problem anymore".
*/
export function useAgentModelKeyStatus(entityId: string): AgentModelKeyStatus {
const config = useAtomValue(
@@ -45,6 +58,10 @@ export function useAgentModelKeyStatus(entityId: string): AgentModelKeyStatus {
// undefined, so we treat the vault as unresolved and never assert a missing key from empty slots.
const vaultQuery = useAtomValue(vaultSecretsQueryAtom)
const loading = !Array.isArray(vaultQuery.data)
+ // Raw listSecrets rows (standard + custom provider + named), NOT the static standardSecrets
+ // catalog — that always has one row per known provider regardless of vault state.
+ const vaultEmpty = !loading && (vaultQuery.data as unknown[]).length === 0
+ const keySetupDone = useAtomValue(providerKeySetupDoneAtom)
return useMemo(() => {
const llm = (config as {agent?: {llm?: LlmRef}} | null)?.agent?.llm
@@ -56,16 +73,27 @@ export function useAgentModelKeyStatus(entityId: string): AgentModelKeyStatus {
: model?.includes("/")
? model.split("/")[0]
: null
+ const selfManaged = llm?.connection?.mode === "self_managed"
- const p = provider?.toLowerCase() ?? null
+ const p = normalizeProviderFamily(provider)
const providerEntry = p
? (standardSecrets.find(
(secret) =>
- providerFromEnvName(secret.name ?? "") === p ||
- (secret.title ?? "").toLowerCase() === p,
+ normalizeProviderFamily((secret.name ?? "").replace(/_api_key$/i, "")) ===
+ p || normalizeProviderFamily(secret.title) === p,
) ?? null)
: null
- return {provider, model, hasKey: !!providerEntry?.key, providerEntry, loading}
- }, [config, standardSecrets, loading])
+ const gateActive =
+ !loading && vaultEmpty && !selfManaged && !keySetupDone && !!providerEntry
+
+ return {
+ provider,
+ model,
+ hasKey: !!providerEntry?.key,
+ providerEntry,
+ loading,
+ gateActive,
+ }
+ }, [config, standardSecrets, loading, vaultEmpty, keySetupDone])
}
diff --git a/web/oss/src/components/DrillInView/OSSdrillInUIProvider.tsx b/web/oss/src/components/DrillInView/OSSdrillInUIProvider.tsx
index 59d59ba220..b67514a0ab 100644
--- a/web/oss/src/components/DrillInView/OSSdrillInUIProvider.tsx
+++ b/web/oss/src/components/DrillInView/OSSdrillInUIProvider.tsx
@@ -71,6 +71,7 @@ import {atomWithQuery} from "jotai-tanstack-query"
import {useLLMProviderConfig} from "@/oss/hooks/useLLMProviderConfig"
import {isToolsEnabled} from "@/oss/lib/helpers/isEE"
+import {isDemo} from "@/oss/lib/helpers/utils"
interface OSSdrillInUIProviderProps {
children: ReactNode
@@ -645,6 +646,9 @@ export function OSSdrillInUIProvider({children}: OSSdrillInUIProviderProps) {
const {llmProviderConfig, overlay: llmProviderOverlay} = useLLMProviderConfig()
const toolsEnabled = isToolsEnabled()
const workflowReference = useWorkflowReferenceBridge()
+ // Deployment policy, never changes at runtime — not memoized. Gates the Provider credentials
+ // section's "Use subscription" toggle (design.md D6, docs/design/connect-model-drawer).
+ const deployment = {isCloud: isDemo()}
if (!toolsEnabled) {
return (
@@ -655,6 +659,7 @@ export function OSSdrillInUIProvider({children}: OSSdrillInUIProviderProps) {
EditorProvider,
SharedEditor,
workflowReference,
+ deployment,
}}
>
{children}
@@ -669,6 +674,7 @@ export function OSSdrillInUIProvider({children}: OSSdrillInUIProviderProps) {
{children}
@@ -681,10 +687,12 @@ function GatewayToolsEnabledProvider({
children,
llmProviderConfig,
workflowReference,
+ deployment,
}: {
children: ReactNode
llmProviderConfig: ReturnType["llmProviderConfig"]
workflowReference: WorkflowReferenceBridge
+ deployment: {isCloud: boolean}
}) {
const {connections, isLoading} = useToolConnectionsQuery()
const setCatalogDrawerOpen = useSetAtom(toolCatalogDrawerOpenAtom)
@@ -739,6 +747,7 @@ function GatewayToolsEnabledProvider({
SharedEditor,
gatewayTools,
workflowReference,
+ deployment,
}}
>
{children}
diff --git a/web/oss/src/components/ModelRegistry/Drawers/ConfigureProviderDrawer/assets/ConfigureProviderDrawerContent.tsx b/web/oss/src/components/ModelRegistry/Drawers/ConfigureProviderDrawer/assets/ConfigureProviderDrawerContent.tsx
index cf2a5b0dba..67b83faffb 100644
--- a/web/oss/src/components/ModelRegistry/Drawers/ConfigureProviderDrawer/assets/ConfigureProviderDrawerContent.tsx
+++ b/web/oss/src/components/ModelRegistry/Drawers/ConfigureProviderDrawer/assets/ConfigureProviderDrawerContent.tsx
@@ -1,372 +1,11 @@
-import React, {useEffect, useMemo, useState} from "react"
+import {CustomProviderForm} from "@agenta/entity-ui/secretProvider"
-import {
- PROVIDER_KINDS,
- PROVIDER_LABELS,
- STANDARD_PROVIDER_KINDS,
- useVaultSecret,
-} from "@agenta/entities/secret"
-import type {LlmProvider} from "@agenta/shared/types"
-import {SelectLLMProviderBase, type ProviderGroup} from "@agenta/ui/select-llm-provider"
-import {capitalize} from "@agenta/ui/select-llm-provider"
-import {Plus, WarningCircle} from "@phosphor-icons/react"
-import {Button, Form, Input, Typography} from "antd"
-import {useWatch} from "antd/lib/form/Form"
-
-import {isSlugInputValid} from "@/oss/lib/helpers/utils"
-
-import LabelInput from "../../../assets/LabelInput"
-
-import {PROVIDER_AUTH_REQUIREMENTS, PROVIDER_FIELDS} from "./constants"
-import ModelNameInput from "./ModelNameInput"
import {ConfigureProviderDrawerContentProps} from "./types"
-const {Text} = Typography
-
-/**
- * Optional render metadata you can attach to each PROVIDER_FIELDS item.
- * Example:
- * {
- * key: "vertexCredentials",
- * label: "Vertex Credentials (JSON)",
- * model: ["vertex_ai"],
- * attributes: { kind: "json", rows: 10, monospace: true, strict: true }
- * }
- */
-type FieldAttributes =
- | {kind: "text"; type?: "text" | "password" | "url"; inputType?: "text" | "password" | "url"}
- | {kind: "textarea"; rows?: number; monospace?: boolean}
- | {kind: "json"; rows?: number; monospace?: boolean; strict?: boolean}
-
-interface FieldWithAttributes {
- attributes?: FieldAttributes
- key: string
- label: string
- placeholder?: string
- required?: boolean
- model?: string[]
- note?: string
-}
-
-/** Render control based on field.attributes */
-const renderControl = (field: FieldWithAttributes, isRequired?: boolean) => {
- const a = field.attributes
-
- if (!a || a.kind === "text") {
- // Keep your existing single-line input
- return (
-
- )
- }
-
- if (a.kind === "textarea") {
- return (
-
@@ -61,7 +75,8 @@ export function useLLMProviderConfig() {
const configureProviderDrawer = (
setIsConfigProviderOpen(false)}
+ initialProviderKind={initialProviderKind ?? undefined}
+ onClose={closeConfigureProvider}
/>
)
@@ -69,8 +84,9 @@ export function useLLMProviderConfig() {
() => ({
extraOptionGroups,
footerContent,
+ openConfigureProvider,
}),
- [extraOptionGroups, footerContent],
+ [extraOptionGroups, footerContent, openConfigureProvider],
)
return useMemo(
diff --git a/web/oss/src/lib/helpers/utils.ts b/web/oss/src/lib/helpers/utils.ts
index 6e6505a3f5..9b98e4f18e 100644
--- a/web/oss/src/lib/helpers/utils.ts
+++ b/web/oss/src/lib/helpers/utils.ts
@@ -2,6 +2,7 @@ import type {LlmProvider} from "@agenta/shared/types"
import {
dataUriToObjectUrl,
isBase64,
+ isSlugInputValid,
isUrl,
removeEmptyFromObjects as sharedRemoveEmptyFromObjects,
safeJson5Parse,
@@ -43,10 +44,8 @@ export const isVariantNameInputValid = (input: string) => {
return URL_SAFE.test(input)
}
-// Slugs go into URLs / identifiers and stay constrained to [a-zA-Z0-9_-].
-export const isSlugInputValid = (input: string) => {
- return URL_SAFE.test(input)
-}
+// Moved to @agenta/shared/utils; re-exported here so existing oss imports keep working.
+export {isSlugInputValid}
export const delay = (ms: number) => new Promise((res) => setTimeout(res, ms))
diff --git a/web/packages/agenta-entities/src/secret/core/index.ts b/web/packages/agenta-entities/src/secret/core/index.ts
index fd9057ab7f..f38ff1a4e9 100644
--- a/web/packages/agenta-entities/src/secret/core/index.ts
+++ b/web/packages/agenta-entities/src/secret/core/index.ts
@@ -33,3 +33,10 @@ export {
transformCustomSecretPayloadData,
getEnvNameMap,
} from "./transforms"
+
+export type {ProviderFieldAttributes, ProviderFieldConfig} from "./providerFields"
+export {
+ CUSTOM_PROVIDER_KIND_FAMILIES,
+ PROVIDER_AUTH_REQUIREMENTS,
+ PROVIDER_FIELDS,
+} from "./providerFields"
diff --git a/web/oss/src/components/ModelRegistry/Drawers/ConfigureProviderDrawer/assets/constants.ts b/web/packages/agenta-entities/src/secret/core/providerFields.ts
similarity index 76%
rename from web/oss/src/components/ModelRegistry/Drawers/ConfigureProviderDrawer/assets/constants.ts
rename to web/packages/agenta-entities/src/secret/core/providerFields.ts
index e336d66de2..0cd9f2deed 100644
--- a/web/oss/src/components/ModelRegistry/Drawers/ConfigureProviderDrawer/assets/constants.ts
+++ b/web/packages/agenta-entities/src/secret/core/providerFields.ts
@@ -1,15 +1,42 @@
-import {STANDARD_PROVIDER_KINDS} from "@agenta/entities/secret"
+/**
+ * Custom-provider form field catalog — declarative field list + per-provider auth
+ * requirements for the "Configure provider" drawer. Data-driven so the form component
+ * has no provider-specific branching.
+ */
+
import type {LlmProvider} from "@agenta/shared/types"
-export const PROVIDER_FIELDS: {
+import {STANDARD_PROVIDER_KINDS} from "./types"
+
+/**
+ * Render metadata attached to a `PROVIDER_FIELDS` item, e.g.
+ * `{kind: "json", rows: 10, monospace: true, strict: true}`.
+ */
+export type ProviderFieldAttributes =
+ | {kind: "text"; type?: "text" | "password" | "url"; inputType?: "text" | "password" | "url"}
+ | {kind: "textarea"; rows?: number; monospace?: boolean}
+ | {kind: "json"; rows?: number; monospace?: boolean; strict?: boolean}
+
+export interface ProviderFieldConfig {
key: keyof LlmProvider
label: string
placeholder: string
note?: string
required?: boolean
model?: string[]
- attributes?: Record
-}[] = [
+ attributes?: ProviderFieldAttributes
+}
+
+/** Model families each custom-provider kind can host ("*" = any) — pragmatic map, pending owner confirmation. */
+export const CUSTOM_PROVIDER_KIND_FAMILIES: Record = {
+ azure: ["openai"],
+ bedrock: ["anthropic", "meta", "amazon", "mistral"],
+ vertex_ai: ["google", "gemini", "anthropic"],
+ sagemaker: "*",
+ custom: "*",
+}
+
+export const PROVIDER_FIELDS: ProviderFieldConfig[] = [
{
key: "name",
label: "Name",
diff --git a/web/packages/agenta-entities/src/secret/index.ts b/web/packages/agenta-entities/src/secret/index.ts
index 571f52469d..ca8632343e 100644
--- a/web/packages/agenta-entities/src/secret/index.ts
+++ b/web/packages/agenta-entities/src/secret/index.ts
@@ -46,6 +46,8 @@ export type {
StandardProviderSettingsDto,
UpdateSecretDto,
VaultMigrationStatus,
+ ProviderFieldAttributes,
+ ProviderFieldConfig,
} from "./core"
export {
@@ -60,6 +62,9 @@ export {
transformCustomProviderPayloadData,
transformCustomSecretPayloadData,
transformSecret,
+ CUSTOM_PROVIDER_KIND_FAMILIES,
+ PROVIDER_AUTH_REQUIREMENTS,
+ PROVIDER_FIELDS,
} from "./core"
// ============================================================================
@@ -86,5 +91,6 @@ export {
customNamedSecretsAtom,
deleteSecretAtom,
migrateVaultKeysAtom,
+ providerKeySetupDoneAtom,
useVaultSecret,
} from "./state"
diff --git a/web/packages/agenta-entities/src/secret/state/atoms.ts b/web/packages/agenta-entities/src/secret/state/atoms.ts
index dce6e9e8f9..ca578f14a1 100644
--- a/web/packages/agenta-entities/src/secret/state/atoms.ts
+++ b/web/packages/agenta-entities/src/secret/state/atoms.ts
@@ -38,6 +38,7 @@ import {
removeEmptyFromObjects,
} from "@agenta/shared/utils"
import {atom} from "jotai"
+import {atomWithStorage} from "jotai/utils"
import {atomWithMutation, atomWithQuery} from "jotai-tanstack-query"
import {createVaultSecret, deleteVaultSecret, fetchVaultSecret, updateVaultSecret} from "../api/api"
@@ -76,6 +77,14 @@ export const vaultMigrationAtom = atom({
migrated: false,
})
+/** Persisted "user has connected a provider key at least once" flag, gating the connect-model prompt. */
+export const providerKeySetupDoneAtom = atomWithStorage(
+ "agenta:provider-key-setup-done",
+ false,
+ undefined,
+ {getOnInit: true},
+)
+
/**
* Query atom for fetching vault secrets.
* Only enabled when user is authenticated and a project is selected.
diff --git a/web/packages/agenta-entities/src/secret/state/index.ts b/web/packages/agenta-entities/src/secret/state/index.ts
index dfb8397268..bc8660541c 100644
--- a/web/packages/agenta-entities/src/secret/state/index.ts
+++ b/web/packages/agenta-entities/src/secret/state/index.ts
@@ -12,6 +12,7 @@ export {
customNamedSecretsAtom,
deleteSecretAtom,
migrateVaultKeysAtom,
+ providerKeySetupDoneAtom,
} from "./atoms"
export {useVaultSecret} from "./useVaultSecret"
diff --git a/web/packages/agenta-entities/src/secret/state/useVaultSecret.ts b/web/packages/agenta-entities/src/secret/state/useVaultSecret.ts
index 8edbb5d500..88ba92fbc4 100644
--- a/web/packages/agenta-entities/src/secret/state/useVaultSecret.ts
+++ b/web/packages/agenta-entities/src/secret/state/useVaultSecret.ts
@@ -32,6 +32,7 @@ import {
customSecretsAtom,
deleteSecretAtom,
migrateVaultKeysAtom,
+ providerKeySetupDoneAtom,
standardSecretsAtom,
vaultMigrationAtom,
vaultSecretsQueryAtom,
@@ -65,6 +66,7 @@ export const useVaultSecret = () => {
const createCustomNamedSecret = useSetAtom(createCustomNamedSecretAtom)
const deleteSecret = useSetAtom(deleteSecretAtom)
const migrateKeys = useSetAtom(migrateVaultKeysAtom)
+ const setProviderKeySetupDone = useSetAtom(providerKeySetupDoneAtom)
useEffect(() => {
if (user && !migrationStatus.migrating && !migrationStatus.migrated) {
@@ -79,9 +81,11 @@ export const useVaultSecret = () => {
const handleModifyVaultSecret = useCallback(
async (provider: LlmProvider) => {
await createStandardSecret(provider)
+ // A successful save means setup is done at least once — never re-show the connect gate.
+ setProviderKeySetupDone(true)
vaultQuery.refetch()
},
- [createStandardSecret, vaultQuery],
+ [createStandardSecret, vaultQuery, setProviderKeySetupDone],
)
const handleModifyCustomVaultSecret = useCallback(
diff --git a/web/packages/agenta-entities/src/workflow/commitDiff/classify.ts b/web/packages/agenta-entities/src/workflow/commitDiff/classify.ts
index d6539c8261..7908e3f311 100644
--- a/web/packages/agenta-entities/src/workflow/commitDiff/classify.ts
+++ b/web/packages/agenta-entities/src/workflow/commitDiff/classify.ts
@@ -224,31 +224,26 @@ function prefixed(
}
/**
- * Model & harness — the model identity (`llm.model`), its `provider`, and the harness engine
- * (`harness.kind`), mirroring the config panel's "Model & harness" control section. The rest of
- * `llm` (connection/auth) belongs to Advanced — the config panel groups it there.
+ * Model & harness — the model identity (`llm.model`), all of `llm` (provider + connection/auth),
+ * and the harness engine (`harness.kind`), mirroring the config panel's "Model & harness" control
+ * section, which now owns connection-mode UI too.
*/
function modelHarnessBucket(v: AgentConfigView): Record {
const out: Record = {}
if (v.model !== undefined) out["llm.model"] = v.model
- if (isPlainObj(v.llm) && v.llm.provider !== undefined) out["llm.provider"] = v.llm.provider
+ Object.assign(out, prefixed("llm", v.llm))
if (isPlainObj(v.harness) && "kind" in v.harness) out["harness.kind"] = v.harness.kind
return out
}
/**
* Advanced — everything the config panel *artificially groups* under "Advanced", which lives in
- * several JSON locations: the llm auth/connection (`llm.*` minus the model), generation params,
- * the runner/sandbox execution sections, and the harness's non-`kind` knobs (e.g. permissions).
+ * several JSON locations: generation params, the runner/sandbox execution sections, and the
+ * harness's non-`kind` knobs (e.g. permissions). `llm` in full belongs to Model & harness.
*/
function advancedBucket(v: AgentConfigView): Record {
const out: Record = {}
for (const key of PARAM_KEYS) if (v.params[key] !== undefined) out[key] = v.params[key]
- // Authentication group: everything in `llm` except model + provider (those are Model & harness).
- if (isPlainObj(v.llm)) {
- const {model: _model, provider: _provider, ...rest} = v.llm
- Object.assign(out, prefixed("llm", rest))
- }
Object.assign(out, prefixed("runner", v.runner))
Object.assign(out, prefixed("sandbox", v.sandbox))
if (isPlainObj(v.harness)) {
diff --git a/web/packages/agenta-entities/src/workflow/index.ts b/web/packages/agenta-entities/src/workflow/index.ts
index 70a54a3757..e2ac75cf46 100644
--- a/web/packages/agenta-entities/src/workflow/index.ts
+++ b/web/packages/agenta-entities/src/workflow/index.ts
@@ -366,6 +366,10 @@ export {
createEphemeralAppFromTemplate,
type AppType,
type CreateEphemeralAppFromTemplateParams,
+ // Agent creation preferences (last-used harness/model/connection default)
+ agentCreationPrefsAtom,
+ applyAgentCreationPrefs,
+ type AgentCreationPrefs,
} from "./state"
// ============================================================================
diff --git a/web/packages/agenta-entities/src/workflow/state/agentCreationPrefs.ts b/web/packages/agenta-entities/src/workflow/state/agentCreationPrefs.ts
new file mode 100644
index 0000000000..66258ac841
--- /dev/null
+++ b/web/packages/agenta-entities/src/workflow/state/agentCreationPrefs.ts
@@ -0,0 +1,67 @@
+/**
+ * Agent creation preferences — the user's last-used harness/model/connection, persisted so a NEW
+ * agent defaults to it instead of only the backend template. Captured when the Model & harness
+ * section is saved (AgentTemplateControl.saveSection); applied when a new agent ephemeral is
+ * minted (createEphemeralAppFromTemplate, appUtils.ts). Versioned for future shape migrations.
+ */
+import {atomWithStorage} from "jotai/utils"
+
+export interface AgentCreationPrefs {
+ version: 1
+ harness?: string
+ model?: string
+ provider?: string
+ connectionMode?: string
+}
+
+const DEFAULT_PREFS: AgentCreationPrefs = {version: 1}
+
+export const agentCreationPrefsAtom = atomWithStorage(
+ "agenta:agent-creation-prefs",
+ DEFAULT_PREFS,
+ undefined,
+ {getOnInit: true},
+)
+
+/**
+ * Overlay saved prefs onto a fresh agent template's config (`parameters.agent`). Only sets fields
+ * the prefs actually carry — the template stays the base for everything else. No harness-catalog
+ * validation here: the capability map isn't at hand at ephemeral-mint time without a fresh fetch,
+ * so a stale/unknown harness pref is written through as-is rather than silently dropped.
+ */
+export function applyAgentCreationPrefs(
+ agentConfig: Record,
+ prefs: AgentCreationPrefs,
+): Record {
+ const next = {...agentConfig}
+
+ if (prefs.harness) {
+ const harness =
+ next.harness && typeof next.harness === "object" && !Array.isArray(next.harness)
+ ? (next.harness as Record)
+ : {}
+ next.harness = {...harness, kind: prefs.harness}
+ }
+
+ if (prefs.model || prefs.provider || prefs.connectionMode) {
+ const llm =
+ next.llm && typeof next.llm === "object" && !Array.isArray(next.llm)
+ ? (next.llm as Record)
+ : {}
+ const nextLlm: Record = {...llm}
+ if (prefs.model) nextLlm.model = prefs.model
+ if (prefs.provider) nextLlm.provider = prefs.provider
+ if (prefs.connectionMode) {
+ const connection =
+ llm.connection &&
+ typeof llm.connection === "object" &&
+ !Array.isArray(llm.connection)
+ ? (llm.connection as Record)
+ : {}
+ nextLlm.connection = {...connection, mode: prefs.connectionMode}
+ }
+ next.llm = nextLlm
+ }
+
+ return next
+}
diff --git a/web/packages/agenta-entities/src/workflow/state/appUtils.ts b/web/packages/agenta-entities/src/workflow/state/appUtils.ts
index 9df6e011ae..e3e5d826df 100644
--- a/web/packages/agenta-entities/src/workflow/state/appUtils.ts
+++ b/web/packages/agenta-entities/src/workflow/state/appUtils.ts
@@ -23,6 +23,7 @@ import {fetchWorkflowCatalogTemplates, inspectWorkflow} from "../api"
import type {Workflow} from "../core"
import {buildWorkflowUri, parseWorkflowKeyFromUri} from "../core"
+import {applyAgentCreationPrefs, agentCreationPrefsAtom} from "./agentCreationPrefs"
import {buildServiceUrlFromUri} from "./helpers"
import {workflowLocalServerDataAtomFamily} from "./store"
@@ -177,10 +178,24 @@ export async function createEphemeralAppFromTemplate({
const rawParameters: Record = {
...((template.data?.parameters as Record | undefined) ?? {}),
}
- const parameters =
+ let parameters =
(syncPromptInputKeysInParameters(rawParameters) as Record | undefined) ??
rawParameters
+ // New agents default to the user's last-used harness/model/connection instead of only the
+ // template default. Both agent-create paths (home composer + onboarding) mint through this one
+ // factory, so overlaying here covers both without duplicating the logic at each call site.
+ if (type === "agent") {
+ const agentPrefs = store.get(agentCreationPrefsAtom)
+ const agentConfig =
+ parameters.agent &&
+ typeof parameters.agent === "object" &&
+ !Array.isArray(parameters.agent)
+ ? (parameters.agent as Record)
+ : {}
+ parameters = {...parameters, agent: applyAgentCreationPrefs(agentConfig, agentPrefs)}
+ }
+
// Build the seedable workflow for a given schema set. Flags are synchronous (no network), so
// seeding early lets the workflow type resolve (`workflowType`) before inspect returns.
const buildWorkflow = (resolvedSchemas: typeof schemas): Workflow =>
diff --git a/web/packages/agenta-entities/src/workflow/state/index.ts b/web/packages/agenta-entities/src/workflow/state/index.ts
index b8769de4ce..cd258ebe55 100644
--- a/web/packages/agenta-entities/src/workflow/state/index.ts
+++ b/web/packages/agenta-entities/src/workflow/state/index.ts
@@ -245,3 +245,13 @@ export {
type AppType,
type CreateEphemeralAppFromTemplateParams,
} from "./appUtils"
+
+// ============================================================================
+// AGENT CREATION PREFERENCES (last-used harness/model/connection default)
+// ============================================================================
+
+export {
+ agentCreationPrefsAtom,
+ applyAgentCreationPrefs,
+ type AgentCreationPrefs,
+} from "./agentCreationPrefs"
diff --git a/web/packages/agenta-entities/tests/unit/agent-commit-diff.test.ts b/web/packages/agenta-entities/tests/unit/agent-commit-diff.test.ts
index 94377d4528..c86daf09e3 100644
--- a/web/packages/agenta-entities/tests/unit/agent-commit-diff.test.ts
+++ b/web/packages/agenta-entities/tests/unit/agent-commit-diff.test.ts
@@ -267,7 +267,9 @@ describe("classifyAgentChanges", () => {
})
})
- it("agent-template: llm auth/connection change lands in Advanced, not Model & harness", () => {
+ it("agent-template: llm connection-mode change lands in Model & harness, not Advanced", () => {
+ // Connection-mode selection lives in the Model & harness drawer now, so its diff must
+ // classify there — not in Advanced (which would show it under the wrong section).
const remote = {
agent: {llm: {model: "opus", provider: "anthropic", connection: {mode: "agenta"}}},
}
@@ -277,10 +279,11 @@ describe("classifyAgentChanges", () => {
},
}
const sections = classifyAgentChanges(local, remote)
- // Model unchanged → no Model & harness section.
- expect(sections.find((s) => s.id === "model")).toBeUndefined()
- const advanced = sections.find((s) => s.id === "params")
- expect(advanced?.scalarChanges).toContainEqual({
+ // No params changed → no Advanced section.
+ expect(sections.find((s) => s.id === "params")).toBeUndefined()
+ const modelHarness = sections.find((s) => s.id === "model")
+ expect(modelHarness?.title).toBe("Model & harness")
+ expect(modelHarness?.scalarChanges).toContainEqual({
key: "llm.connection.mode",
before: "agenta",
after: "self_managed",
diff --git a/web/packages/agenta-entities/tests/unit/agent-creation-prefs.test.ts b/web/packages/agenta-entities/tests/unit/agent-creation-prefs.test.ts
new file mode 100644
index 0000000000..346d08352b
--- /dev/null
+++ b/web/packages/agenta-entities/tests/unit/agent-creation-prefs.test.ts
@@ -0,0 +1,55 @@
+import {describe, expect, it} from "vitest"
+
+import {applyAgentCreationPrefs} from "../../src/workflow/state/agentCreationPrefs"
+
+describe("applyAgentCreationPrefs", () => {
+ it("leaves the template config untouched when no prefs are set", () => {
+ const template = {harness: {kind: "claude"}, llm: {model: "gpt-4o"}}
+ expect(applyAgentCreationPrefs(template, {version: 1})).toEqual(template)
+ })
+
+ it("overlays only the fields the prefs carry, keeping the rest of the template", () => {
+ const template = {
+ harness: {kind: "claude", max_iterations: 10},
+ llm: {model: "gpt-4o", temperature: 0.7},
+ tools: [{name: "gmail_search"}],
+ }
+ const result = applyAgentCreationPrefs(template, {version: 1, harness: "pi_core"})
+ expect(result.harness).toEqual({kind: "pi_core", max_iterations: 10})
+ expect(result.llm).toEqual({model: "gpt-4o", temperature: 0.7})
+ expect(result.tools).toBe(template.tools)
+ })
+
+ it("overlays model/provider/connectionMode without dropping other llm keys", () => {
+ const template = {llm: {model: "gpt-4o", temperature: 0.5}}
+ const result = applyAgentCreationPrefs(template, {
+ version: 1,
+ model: "claude-opus-4",
+ provider: "anthropic",
+ connectionMode: "self_managed",
+ })
+ expect(result.llm).toEqual({
+ model: "claude-opus-4",
+ temperature: 0.5,
+ provider: "anthropic",
+ connection: {mode: "self_managed"},
+ })
+ })
+
+ it("preserves an existing connection slug when only the mode is overlaid", () => {
+ const template = {llm: {model: "gpt-4o", connection: {mode: "agenta", slug: "my-conn"}}}
+ const result = applyAgentCreationPrefs(template, {version: 1, connectionMode: "agenta"})
+ expect(result.llm).toEqual({
+ model: "gpt-4o",
+ connection: {mode: "agenta", slug: "my-conn"},
+ })
+ })
+
+ it("builds harness/llm objects from scratch when the template has none", () => {
+ const result = applyAgentCreationPrefs(
+ {},
+ {version: 1, harness: "claude", model: "claude-opus-4"},
+ )
+ expect(result).toEqual({harness: {kind: "claude"}, llm: {model: "claude-opus-4"}})
+ })
+})
diff --git a/web/packages/agenta-entity-ui/package.json b/web/packages/agenta-entity-ui/package.json
index db4995f1a0..5a2d2f4666 100644
--- a/web/packages/agenta-entity-ui/package.json
+++ b/web/packages/agenta-entity-ui/package.json
@@ -21,6 +21,7 @@
"./gatewayTool": "./src/gatewayTool/index.ts",
"./gatewayTrigger": "./src/gatewayTrigger/index.ts",
"./modals": "./src/modals/index.ts",
+ "./secretProvider": "./src/secretProvider/index.ts",
"./selection": "./src/selection/index.ts",
"./template-format": "./src/template-format/index.ts",
"./testcase": "./src/testcase/index.ts",
diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx
index cea7626756..5969142327 100644
--- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx
+++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx
@@ -24,7 +24,11 @@
import {useCallback, useEffect, useMemo, useRef, useState} from "react"
import type {SchemaProperty} from "@agenta/entities/shared"
-import {workflowBuildKitEnabledAtomFamily, workflowMolecule} from "@agenta/entities/workflow"
+import {
+ agentCreationPrefsAtom,
+ workflowBuildKitEnabledAtomFamily,
+ workflowMolecule,
+} from "@agenta/entities/workflow"
import {
agentItemIdentity,
classifyAgentChanges,
@@ -64,7 +68,7 @@ import {useConfigItemDrawer} from "./agentTemplate/useConfigItemDrawer"
import {useModelHarness} from "./agentTemplate/useModelHarness"
import {agentTemplateLayoutAtom} from "./agentTemplateLayout"
import {ConfigItemDrawer} from "./ConfigItemDrawer"
-import {modelIdFromConfig} from "./connectionUtils"
+import {connectionFromConfig, modelIdFromConfig} from "./connectionUtils"
import {InstructionsDrawer} from "./InstructionsDrawer"
import {JsonObjectEditor} from "./JsonObjectEditor"
import {SectionDrawer} from "./SectionDrawer"
@@ -175,8 +179,22 @@ export function AgentTemplateControl({
// internal auto-correction effect must not leak into `draftConfig`). The live `mh` handles any
// real auto-correction against the entity.
const noopConfigChange = useCallback(() => {}, [])
+ // Single source of truth for "the currently open section has unsaved edits" — shared by the
+ // open-a-new-section guard below and the Save-button gate (`sectionDirty`) so they can't drift.
+ const isCurrentSectionDirty = useCallback(
+ () =>
+ openSection !== null &&
+ sectionBaseline.current !== null &&
+ (!deepEqual(draftConfig, sectionBaseline.current.config) ||
+ draftBuildKit !== sectionBaseline.current.buildKit),
+ [openSection, draftConfig, draftBuildKit],
+ )
const openSectionDrawer = useCallback(
(key: "model-harness" | "advanced") => {
+ // Same section already open: never re-snapshot over a live draft.
+ if (openSection === key) return
+ // Another section is open with unsaved edits: drop the request rather than clobber it.
+ if (isCurrentSectionDirty()) return
const snapshotConfig = (value ?? {}) as Record
const snapshotBuildKit = store.get(
workflowBuildKitEnabledAtomFamily(revisionIdRef.current ?? ""),
@@ -186,7 +204,7 @@ export function AgentTemplateControl({
sectionBaseline.current = {config: snapshotConfig, buildKit: snapshotBuildKit}
setOpenSection(key)
},
- [value, store],
+ [value, store, openSection, isCurrentSectionDirty],
)
const closeSectionDraft = useCallback(() => {
setOpenSection(null)
@@ -199,24 +217,42 @@ export function AgentTemplateControl({
const [openSectionRequest, setOpenSectionRequest] = useAtom(openAgentConfigSectionAtom)
useEffect(() => {
if (!openSectionRequest) return
+ // Always clears the request, even when openSectionDrawer no-ops on a dirty open section —
+ // the request is intentionally dropped rather than queued.
openSectionDrawer(openSectionRequest)
setOpenSectionRequest(null)
}, [openSectionRequest, openSectionDrawer, setOpenSectionRequest])
// Cancel: nothing was written live, so just drop the draft.
const cancelSection = closeSectionDraft
const saveSection = useCallback(() => {
- if (draftConfig !== null) onChange(draftConfig)
+ if (draftConfig !== null) {
+ onChange(draftConfig)
+ // Remember the harness/model/connection pick for future agent creations — only on an
+ // explicit Model & harness save, not on every keystroke or the Advanced section.
+ if (openSection === "model-harness") {
+ const harness = draftConfig.harness
+ const harnessKind =
+ harness && typeof harness === "object" && !Array.isArray(harness)
+ ? (harness as Record).kind
+ : undefined
+ const modelId = modelIdFromConfig(draftConfig.llm)
+ const connection = connectionFromConfig(draftConfig.llm)
+ store.set(agentCreationPrefsAtom, (prev) => ({
+ version: 1,
+ harness: typeof harnessKind === "string" ? harnessKind : prev.harness,
+ model: modelId ?? prev.model,
+ provider: connection.provider ?? prev.provider,
+ connectionMode: connection.mode ?? prev.connectionMode,
+ }))
+ }
+ }
if (draftBuildKit !== null) {
store.set(workflowBuildKitEnabledAtomFamily(revisionIdRef.current ?? ""), draftBuildKit)
}
closeSectionDraft()
- }, [draftConfig, draftBuildKit, onChange, store, closeSectionDraft])
+ }, [draftConfig, draftBuildKit, openSection, onChange, store, closeSectionDraft])
// Enable Save only when the draft actually differs from what we opened with (config or build-kit).
- const sectionDirty =
- openSection !== null &&
- sectionBaseline.current !== null &&
- (!deepEqual(draftConfig, sectionBaseline.current.config) ||
- draftBuildKit !== sectionBaseline.current.buildKit)
+ const sectionDirty = isCurrentSectionDirty()
// Layout (accordion / tabs / cards) is a global persisted preference; the panel only reads it.
const layout = useAtomValue(agentTemplateLayoutAtom)
@@ -846,6 +882,7 @@ export function AgentTemplateControl({
onCancel={cancelSection}
onSave={saveSection}
disabled={disabled || !sectionDirty}
+ dirty={sectionDirty}
width={mhDraft.modelHarnessDrawerWidth}
>
{mhDraft.modelHarnessDrawerBody}
@@ -858,6 +895,7 @@ export function AgentTemplateControl({
onCancel={cancelSection}
onSave={saveSection}
disabled={disabled || !sectionDirty}
+ dirty={sectionDirty}
width={880}
>
{mhDraft.advancedDrawerBody}
diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SectionDrawer.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SectionDrawer.tsx
index b98f0b3483..6d96ec8721 100644
--- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SectionDrawer.tsx
+++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SectionDrawer.tsx
@@ -8,8 +8,9 @@
*
* Built on the shared `EnhancedDrawer`.
*/
-import {type ReactNode} from "react"
+import {type ReactNode, useCallback, useState} from "react"
+import {EnhancedModal} from "@agenta/ui"
import {EnhancedDrawer} from "@agenta/ui/drawer"
import {Button} from "antd"
@@ -20,6 +21,8 @@ export interface SectionDrawerProps {
onCancel: () => void
onSave: () => void
disabled?: boolean
+ // When true, closing via scrim/X asks for confirmation instead of discarding silently.
+ dirty?: boolean
width?: number
footerNote?: ReactNode
children: ReactNode
@@ -32,44 +35,88 @@ export function SectionDrawer({
onCancel,
onSave,
disabled = false,
+ dirty = false,
width = 720,
footerNote = "Draft — applies on save",
children,
}: SectionDrawerProps) {
+ const [confirmOpen, setConfirmOpen] = useState(false)
+ // Scrim/X close: guard with a confirm when dirty; the footer Cancel button bypasses this.
+ const handleRequestClose = useCallback(() => {
+ if (dirty) {
+ setConfirmOpen(true)
+ } else {
+ onCancel()
+ }
+ }, [dirty, onCancel])
return (
-
- {icon ? {icon} : null}
- {title}
-
- }
- footer={
-
-
- {footerNote}
-
-
-
-
+ }
+ footer={
+
+
+ {footerNote}
+
+
+
+
+
+
+ }
+ // The body itself doesn't scroll — the content (a full-height flex row) gives each
+ // panel its own overflow, so the left and right panels scroll independently.
+ styles={{body: {padding: 16, overflow: "hidden"}}}
+ >
+ {children}
+
+ setConfirmOpen(false)}
+ title="You have unsaved changes"
+ width={420}
+ footer={
+
+
+
+
-
- }
- // The body itself doesn't scroll — the content (a full-height flex row) gives each
- // panel its own overflow, so the left and right panels scroll independently.
- styles={{body: {padding: 16, overflow: "hidden"}}}
- >
- {children}
-
+ }
+ >
+
Save your changes to this agent draft, or discard them?
+
+ >
)
}
diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderCredentialsSection.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderCredentialsSection.tsx
new file mode 100644
index 0000000000..85224ef9ef
--- /dev/null
+++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderCredentialsSection.tsx
@@ -0,0 +1,494 @@
+/**
+ * ProviderCredentialsSection
+ *
+ * The "Provider credentials" pane, rendered as a `ConfigAccordionSection` so it reads as a peer of
+ * the Harness and Model sections: a segmented "Use API key" / "Use subscription" toggle (header
+ * right) over either (a) a provider rail + key form, or (b) a self-managed info card. Vault
+ * reads/writes
+ * (standard keys) happen directly against the secret entity's atoms/hooks — only the agent draft's
+ * mode flows through props, because key saves are immediate and independent of the drawer draft
+ * (locked decision; design.md §3.1). Connections are always the project default: there is no named-
+ * connection picker here (owner call, post-design-doc) — a picked model can still auto-fill a vault
+ * connection's slug (the model picker threads a vault option's `connectionSlug` metadata into
+ * `useModelHarness.writeModel`), just not through a Select.
+ *
+ * The rail is filtered to the selected model: its own standard provider, the configured vault
+ * custom providers whose kind can host that family (`CUSTOM_PROVIDER_KIND_FAMILIES`), and "Add …"
+ * rows for the remaining kinds that could. No model family = no filter (all rows show).
+ *
+ * Adding a new custom provider (Azure/Bedrock/Vertex AI/OpenAI-compatible) opens the host's
+ * "Configure provider" drawer via `openConfigureProvider` (from `DrillInUIContext.llmProviderConfig`)
+ * instead of an inline form — this package can't import the OSS drawer directly.
+ *
+ * Design: docs/design/connect-model-drawer/design.md §3, §7, §8.
+ */
+import {useEffect, useMemo, useState, type ReactNode} from "react"
+
+import {
+ CUSTOM_PROVIDER_KIND_FAMILIES,
+ customSecretsAtom,
+ CustomProviderKind,
+ PROVIDER_LABELS,
+ standardSecretsAtom,
+} from "@agenta/entities/secret"
+import type {LlmProvider} from "@agenta/shared/types"
+import {normalizeProviderFamily} from "@agenta/shared/utils"
+import {ConfigAccordionSection} from "@agenta/ui/components/presentational"
+import {getProviderIcon} from "@agenta/ui/select-llm-provider"
+import {cn} from "@agenta/ui/styles"
+import {Key, Plus, Terminal} from "@phosphor-icons/react"
+import {Segmented, Typography} from "antd"
+import {useAtomValue} from "jotai"
+
+import type {ConnectionMode} from "../connectionUtils"
+
+import ProviderKeyField from "./ProviderKeyField"
+
+const DEFAULT_SELF_HOSTING_GUIDE_URL = "https://docs.agenta.ai/self-host/quick-start"
+
+export interface ProviderCredentialsSectionProps {
+ // config — the agent draft's credential-relevant slice (config.llm.connection.mode)
+ mode: ConnectionMode
+ onModeChange: (mode: ConnectionMode) => void
+
+ // routing/context — what the current model selection points at; filters + auto-highlights the rail
+ selectedProviderFamily: string | null
+ /** The model's named vault connection (config.llm.connection.slug), when it has one — that
+ * connection's rail row wins the auto-highlight over the standard-provider match. */
+ selectedConnectionSlug?: string | null
+
+ // policy — what the environment allows
+ modeOptions: ConnectionMode[]
+ /** Gates the self-managed card's "Not on cloud" badge (design.md D6). The "Use subscription"
+ * toggle itself is always clickable, cloud included — the card + badge are the explanation. */
+ isCloud: boolean
+ /** Self-hosting guide link target; falls back to the docs quick-start page. */
+ selfHostingGuideUrl?: string
+ /** The selected provider has a standard vault slot but no key yet — drives the header's
+ * "Connect key" affordance. */
+ providerNeedsKey?: boolean
+ /** Opens the host's "Configure provider" drawer for a NEW provider with `kind` pre-selected.
+ * Absent hides the "Add …" rows (a host with no drawer wired up). */
+ openConfigureProvider?: (kind: string) => void
+
+ // presentation
+ disabled?: boolean
+}
+
+const STANDARD_PREFIX = "std:"
+const CUSTOM_PREFIX = "custom:"
+
+/** The four provider kinds the "Configure provider" drawer supports beyond the standard catalog
+ * (verified against `PROVIDER_FIELDS`/`CustomProviderForm`'s `customProviders` list). */
+const CUSTOM_PROVIDER_ROWS: {kind: string; label: string}[] = [
+ {kind: CustomProviderKind.Azure, label: "Azure OpenAI"},
+ {kind: CustomProviderKind.Bedrock, label: "AWS Bedrock"},
+ {kind: CustomProviderKind.VertexAi, label: "Vertex AI"},
+ {kind: CustomProviderKind.Custom, label: "Custom provider"},
+]
+
+/** Family spellings that must compare equal across catalog names, vault env names, and titles. */
+const FAMILY_ALIASES: Record = {
+ google: ["gemini", "googlegemini"],
+ gemini: ["google", "googlegemini"],
+ googlegemini: ["google", "gemini"],
+ mistral: ["mistralai"],
+ mistralai: ["mistral"],
+}
+
+function familyCandidates(family: string): Set {
+ return new Set([family, ...(FAMILY_ALIASES[family] ?? [])])
+}
+
+/** Same family match `useModelHarness`'s `providerVaultEntry` uses: env-var name minus the
+ * `_API_KEY` suffix, or the title, case-insensitively. */
+function standardSecretFamily(secret: LlmProvider): string {
+ return normalizeProviderFamily((secret.name ?? "").replace(/_api_key$/i, ""))
+}
+
+function standardSecretMatches(secret: LlmProvider, candidates: Set): boolean {
+ return (
+ candidates.has(standardSecretFamily(secret)) ||
+ candidates.has(normalizeProviderFamily(secret.title))
+ )
+}
+
+/** Whether a custom-provider KIND (azure/bedrock/…) can host the selected model family. Mirrors
+ * connectionUtils' `harnessReachesCustomProviderKind` two-flavor split: a DEPLOYMENT kind
+ * (azure/bedrock/vertex_ai/sagemaker/custom) is gated by `CUSTOM_PROVIDER_KIND_FAMILIES`, but a
+ * kind absent from that map is a plain PROVIDER FAMILY (e.g. a second "openai"-kind connection) —
+ * the kind itself IS the family, so it serves the selected model whenever the kind matches. Without
+ * this branch the rail hides plain-family custom connections that the model dropdown still shows. */
+function kindServesFamily(kind: string | null | undefined, candidates: Set): boolean {
+ const normalizedKind = (kind ?? "").toLowerCase()
+ const families = CUSTOM_PROVIDER_KIND_FAMILIES[normalizedKind]
+ if (families === "*") return true
+ if (families) return families.some((family) => candidates.has(normalizeProviderFamily(family)))
+ return candidates.has(normalizeProviderFamily(normalizedKind))
+}
+
+/** Icon renderer helper (not a component): keeps the icon lookup out of render so
+ * `react-hooks/static-components` doesn't see a component created during render. */
+function renderProviderIcon(family: string): ReactNode {
+ const Icon = getProviderIcon(family)
+ return Icon ? : null
+}
+
+function ProviderTile({family, label}: {family: string; label: string}) {
+ const icon = renderProviderIcon(family)
+ return (
+ // Fixed-light logo tile: brand glyphs are dark-filled and would vanish on dark fills.
+
+ {icon ?? (label.charAt(0).toUpperCase() || "?")}
+
+ )
+}
+
+/** One rail row shape for every entry (providers AND the add rows): fixed h-9, 22px logo tile,
+ * one flex pattern — unselected transparent, hover subtle fill, selected filled + semibold. */
+function RailRow({
+ active,
+ disabled,
+ onClick,
+ tile,
+ label,
+ trailing,
+}: {
+ active?: boolean
+ disabled?: boolean
+ onClick: () => void
+ tile: ReactNode
+ label: string
+ trailing?: ReactNode
+}) {
+ return (
+
+ )
+}
+
+export function ProviderCredentialsSection({
+ mode,
+ onModeChange,
+ selectedProviderFamily,
+ selectedConnectionSlug,
+ modeOptions,
+ isCloud,
+ selfHostingGuideUrl,
+ providerNeedsKey,
+ openConfigureProvider,
+ disabled,
+}: ProviderCredentialsSectionProps) {
+ const standardSecrets = useAtomValue(standardSecretsAtom)
+ const customSecrets = useAtomValue(customSecretsAtom)
+
+ // The model's own named vault connection, when it has one. It always gets a rail row and wins
+ // the auto-highlight — a vault model id often encodes no catalog family, so without this the
+ // rail would fall back to an unrelated standard provider.
+ const slugSecret = useMemo(
+ () =>
+ selectedConnectionSlug
+ ? (customSecrets.find((secret) => secret.name === selectedConnectionSlug) ?? null)
+ : null,
+ [customSecrets, selectedConnectionSlug],
+ )
+
+ // Filter the rail to the selected model's family (owner rule: only the providers that can
+ // serve the picked model). No family: a named vault connection narrows the rail to itself;
+ // otherwise there is nothing to filter by and everything shows.
+ const family = normalizeProviderFamily(selectedProviderFamily)
+ const candidates = useMemo(() => (family ? familyCandidates(family) : null), [family])
+ const visibleStandardSecrets = useMemo(() => {
+ if (candidates)
+ return standardSecrets.filter((secret) => standardSecretMatches(secret, candidates))
+ return slugSecret ? [] : standardSecrets
+ }, [standardSecrets, candidates, slugSecret])
+ const visibleCustomSecrets = useMemo(() => {
+ const base = candidates
+ ? customSecrets.filter((secret) => kindServesFamily(secret.provider, candidates))
+ : slugSecret
+ ? [slugSecret]
+ : customSecrets
+ return slugSecret && !base.some((secret) => secret.name === slugSecret.name)
+ ? [slugSecret, ...base]
+ : base
+ }, [customSecrets, candidates, slugSecret])
+ const visibleAddRows = useMemo(
+ () =>
+ candidates
+ ? CUSTOM_PROVIDER_ROWS.filter((row) => kindServesFamily(row.kind, candidates))
+ : CUSTOM_PROVIDER_ROWS,
+ [candidates],
+ )
+
+ // The rail row matching the agent's current provider (auto-highlight): its named vault
+ // connection first, else the first visible row — the filter already reduced the standard list
+ // to the selected family's entry when there is one.
+ const autoKey = useMemo(() => {
+ if (slugSecret?.name) return `${CUSTOM_PREFIX}${slugSecret.name}`
+ const standard = visibleStandardSecrets[0]
+ if (standard) return `${STANDARD_PREFIX}${standard.name}`
+ const custom = visibleCustomSecrets[0]
+ return custom ? `${CUSTOM_PREFIX}${custom.name}` : ""
+ }, [slugSecret, visibleStandardSecrets, visibleCustomSecrets])
+
+ // `null` = follow `autoKey`; set once the user browses another provider so browsing doesn't
+ // change the agent's model (design.md §3.2).
+ const [manualKey, setManualKey] = useState(null)
+ useEffect(() => {
+ setManualKey(null)
+ }, [selectedProviderFamily, selectedConnectionSlug])
+ // A manual pick that the filter no longer shows falls back to the auto row.
+ const visibleKeys = useMemo(() => {
+ const keys = new Set()
+ for (const secret of visibleStandardSecrets) keys.add(`${STANDARD_PREFIX}${secret.name}`)
+ for (const secret of visibleCustomSecrets) keys.add(`${CUSTOM_PREFIX}${secret.name}`)
+ return keys
+ }, [visibleStandardSecrets, visibleCustomSecrets])
+ const activeKey = manualKey && visibleKeys.has(manualKey) ? manualKey : autoKey
+
+ const selectedStandardSecret = activeKey.startsWith(STANDARD_PREFIX)
+ ? (standardSecrets.find((s) => s.name === activeKey.slice(STANDARD_PREFIX.length)) ?? null)
+ : null
+
+ // A selected custom-provider rail row: no inline edit form (that lives in the host's Settings →
+ // Secrets drawer now), just a read-only summary so the pane isn't blank.
+ const selectedCustomProvider = activeKey.startsWith(CUSTOM_PREFIX)
+ ? (customSecrets.find((s) => s.name === activeKey.slice(CUSTOM_PREFIX.length)) ?? null)
+ : null
+
+ // Only offer a mode the harness actually publishes — a self_managed-only harness (no "agenta")
+ // must not offer "Use API key", and vice versa. Owner rule: self_managed is always clickable
+ // when capability-allowed, cloud included (isCloud only gates the badge on the card below).
+ const toggleOptions = useMemo(
+ () =>
+ [
+ modeOptions.includes("agenta") ? {label: "Use API key", value: "agenta"} : null,
+ modeOptions.includes("self_managed")
+ ? {label: "Use subscription", value: "self_managed"}
+ : null,
+ ].filter((option): option is {label: string; value: ConnectionMode} => option !== null),
+ [modeOptions],
+ )
+ // Hidden entirely when there's nothing to toggle between (harness allows only one mode).
+ const showToggle = toggleOptions.length > 1
+ const guideUrl = selfHostingGuideUrl || DEFAULT_SELF_HOSTING_GUIDE_URL
+
+ return (
+ }
+ title="Provider credentials"
+ status={providerNeedsKey ? "warning" : "complete"}
+ titleBadge={
+ providerNeedsKey ? (
+
+ Connect key
+
+ ) : null
+ }
+ summary={mode === "self_managed" ? "Subscription" : "API key"}
+ summaryCollapsedOnly
+ noDivider
+ extra={
+ showToggle ? (
+ onModeChange(value as ConnectionMode)}
+ options={toggleOptions}
+ className={cn(
+ "rounded-md border border-solid border-[var(--ag-colorBorder)]",
+ // antd's default selected-thumb bg/track bg both resolve to
+ // near-white in light mode, so the "active" segment is invisible —
+ // force a strong, theme-inverted fill instead (dark-navy-on-white
+ // in light mode, near-white-on-near-black in dark mode).
+ "[&_.ant-segmented-item-selected]:!bg-[var(--ag-colorText)] [&_.ant-segmented-item-selected]:!text-[var(--ag-colorBgContainer)] [&_.ant-segmented-item-selected]:!shadow-none",
+ "[&_.ant-segmented-thumb]:!bg-[var(--ag-colorText)] [&_.ant-segmented-thumb]:!shadow-none",
+ )}
+ />
+ ) : undefined
+ }
+ >
+ {mode === "self_managed" ? (
+
+
+
+
+
+
+ Self-managed
+
+
+
+
+ Use a Claude Code or Codex subscription, or any credential the
+ harness reads from its own environment (env vars, prior logins).
+
+
+
+
+ Agenta stores and injects no key.
+
+
+
+
+ Requires a self-hosted Agenta deployment.
+
+
+
+
+
+
+ Read the self-hosting guide →
+
+ {isCloud ? (
+ // fallback until colorErrorBg token lands
+
+ Unavailable in the cloud
+
+ ) : null}
+
+ ) : (
+
+ No provider configured for this model yet — add one from the list.
+
+ )}
+
+
+ )}
+
+ )
+}
+
+export default ProviderCredentialsSection
diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderKeyField.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderKeyField.tsx
index ae954b0a08..250e7079e6 100644
--- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderKeyField.tsx
+++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderKeyField.tsx
@@ -6,13 +6,13 @@ import {CheckCircle} from "@phosphor-icons/react"
import {App, Button, Input, Typography} from "antd"
/**
- * "Provider key" content for the Model & credentials drawer — a key/value pair: a disabled input naming
- * the provider's vault key (e.g. `OPENAI_API_KEY`) beside the secret input for its value. Shows a
- * "connect your key" state when the selected model's provider has no vault key, or a "configured ·
- * replace" state when it does. Saves to the project vault (standard path) + refetches, so the key lands
- * without leaving the playground and the chat gate clears reactively.
+ * Right-pane "API key" form for a standard provider — the Provider credentials section's key form
+ * (evolved from the original Model & credentials drawer field, same immediate-save semantics): a
+ * heading + subtitle, "API key *" input, Save/Replace, a masked "configured" state when a key
+ * exists, and an encryption footnote. Saves to the project vault via `useVaultSecret`, which also
+ * arms `providerKeySetupDoneAtom` — no drawer Save step, so the "Connect key" gate clears reactively.
*/
-const ProviderKeyField = ({provider}: {provider: LlmProvider}) => {
+const ProviderKeyField = ({provider, disabled}: {provider: LlmProvider; disabled?: boolean}) => {
const {message} = App.useApp()
const {handleModifyVaultSecret} = useVaultSecret()
const [key, setKey] = useState("")
@@ -20,7 +20,7 @@ const ProviderKeyField = ({provider}: {provider: LlmProvider}) => {
const save = async () => {
const trimmed = key.trim()
- if (!trimmed || saving) return
+ if (!trimmed || saving || disabled) return
setSaving(true)
try {
await handleModifyVaultSecret({...provider, key: trimmed})
@@ -34,37 +34,50 @@ const ProviderKeyField = ({provider}: {provider: LlmProvider}) => {
}
const hasKey = !!provider.key
- const keyName = provider.name ?? provider.title ?? "PROVIDER_API_KEY"
return (
-
- {hasKey ? (
-
-
- Key configured · enter a new value to replace it.
+
+
+
+ {provider.title}
- ) : (
-
- Standard provider · add your key and we'll run this agent with it.
+
+ Standard provider · add your key and we auto-list its models.
+
+ {hasKey ? (
+
+
+ Key configured · enter a new value to replace it.
+
+ ) : null}
+
+
+ This secret is encrypted in transit and at rest.
- )}
-
-
- setKey(e.target.value)}
- onPressEnter={save}
- placeholder={hasKey ? "Enter a new API key" : "Enter your API key"}
- className="flex-1"
- autoFocus={!hasKey}
- />
-
-
- Encrypted in transit and at rest.
-
)
}
diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx
index 787c0fc209..c60d8c90ad 100644
--- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx
+++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx
@@ -2,7 +2,7 @@
* useModelHarness — the Model & harness + Advanced sections (the panel's most stateful part). One
* hook because the model/connection state feeds both; returns each section's summary + bodies.
*/
-import {useCallback, useEffect, useMemo, useState} from "react"
+import {useCallback, useEffect, useMemo, useRef} from "react"
import {
customSecretsAtom,
@@ -11,11 +11,12 @@ import {
} from "@agenta/entities/secret"
import type {SchemaProperty} from "@agenta/entities/shared"
import {harnessCapabilitiesAtomFamily} from "@agenta/entities/workflow"
-import {ConfigAccordionSection, LabeledField} from "@agenta/ui/components/presentational"
+import {normalizeProviderFamily} from "@agenta/shared/utils"
+import {ConfigAccordionSection} from "@agenta/ui/components/presentational"
import {useDrillInUI} from "@agenta/ui/drill-in"
import {SelectLLMProviderBase} from "@agenta/ui/select-llm-provider"
import {cn} from "@agenta/ui/styles"
-import {Check, Cube, Key, Lightbulb, ShieldCheck, Sparkle, Warning} from "@phosphor-icons/react"
+import {Check, Cube, Lightbulb, ShieldCheck, Sparkle, Warning} from "@phosphor-icons/react"
import {Select, Typography} from "antd"
import {useAtomValue} from "jotai"
@@ -27,14 +28,12 @@ import {
buildModelOptionGroups,
composeModelValue,
connectionFromConfig,
- familyFromModelId,
harnessAllowsModel,
modelIdFromConfig,
- namedConnectionOptions,
providerForModel,
vaultModelGroups,
+ vaultPickedProviderFamily,
type ConnectionMode,
- type VaultConnectionEntry,
} from "../connectionUtils"
import {EnumSelectControl} from "../EnumSelectControl"
import {GroupedChoiceControl} from "../GroupedChoiceControl"
@@ -43,7 +42,7 @@ import {PiSettingsControl} from "../PiSettingsControl"
import {SandboxPermissionControl} from "../SandboxPermissionControl"
import {enumLabel} from "./agentTemplateUtils"
-import ProviderKeyField from "./ProviderKeyField"
+import ProviderCredentialsSection from "./ProviderCredentialsSection"
import {useBuildKit} from "./useBuildKit"
type PermissionPolicy = "allow_reads" | "allow" | "ask" | "deny"
@@ -164,14 +163,10 @@ export function useModelHarness({
)
const capabilities = harnessRefKey ? capabilitiesFromCatalog : null
- // The project's stored connections (read-only) for the connection picker. The transformed vault
- // list surfaces custom-provider connections as {type, name, provider}; the resolver matches a
- // named connection by that name (the slug).
+ // The vault query backs `vaultLoaded` below (gates the "needs a key" flag) and the custom_provider
+ // model groups (`vaultModelGroups`); connections themselves are always the project default now,
+ // so there is no named-connection list here.
const vaultQuery = useAtomValue(vaultSecretsQueryAtom)
- const vaultSecrets = useMemo(
- () => (Array.isArray(vaultQuery.data) ? (vaultQuery.data as VaultConnectionEntry[]) : []),
- [vaultQuery.data],
- )
const modeOptions = useMemo(
() => allowedConnectionModes(capabilities, harnessValue),
@@ -184,38 +179,42 @@ export function useModelHarness({
// Inline credential prompt: resolve the selected model's provider family and check whether the
// vault already holds its (standard) key. When it doesn't, the drawer surfaces a key field so the
// user can connect it here. `providerForModel` is the same catalog lookup the model picker uses.
+ // Also fed to the Provider credentials section, which auto-highlights this family in its rail.
const standardSecrets = useAtomValue(standardSecretsAtom)
+ const selectedProviderFamily = useMemo(
+ () => providerForModel(capabilities, harnessValue, modelId) ?? connection.provider ?? null,
+ [capabilities, harnessValue, modelId, connection.provider],
+ )
const providerVaultEntry = useMemo(() => {
- const family = (
- providerForModel(capabilities, harnessValue, modelId) ??
- connection.provider ??
- ""
- ).toLowerCase()
+ const family = normalizeProviderFamily(selectedProviderFamily)
if (!family) return null
return (
standardSecrets.find(
(secret) =>
- (secret.name ?? "").toLowerCase().replace(/_api_key$/, "") === family ||
- (secret.title ?? "").toLowerCase() === family,
+ normalizeProviderFamily((secret.name ?? "").replace(/_api_key$/i, "")) ===
+ family || normalizeProviderFamily(secret.title) === family,
) ?? null
)
- }, [standardSecrets, capabilities, harnessValue, modelId, connection.provider])
+ }, [standardSecrets, selectedProviderFamily])
// Only assert "needs a key" once the vault query has resolved (an array). While it's pending,
// `standardSecretsAtom` returns the static provider catalog with empty keys, so a reload would
// flash a false "Connect key" warning on the section, rail item, and config-panel row.
const vaultLoaded = Array.isArray(vaultQuery.data)
- const providerNeedsKey = vaultLoaded && !!providerVaultEntry && !providerVaultEntry.key
-
- // Model section sub-tabs (rail): pick the model vs connect its provider key. Lands on "key" when a
- // key is missing so the "Set up credentials" banner opens straight onto it; only re-forces on the
- // needs-key → true transition, so manual navigation to "Model" is respected.
- const [modelTab, setModelTab] = useState<"model" | "key">(providerNeedsKey ? "key" : "model")
- useEffect(() => {
- if (providerNeedsKey) setModelTab("key")
- }, [providerNeedsKey])
-
- // The "Add provider" footer + drawer come from context, same source as the completion picker.
- const {llmProviderConfig} = useDrillInUI()
+ // Self-managed agents never need a vault key — the harness signs itself in. Neither does a
+ // named custom-provider connection (agenta mode with a slug): it carries its own credentials,
+ // so a missing STANDARD vault key for the family is not this connection's problem.
+ const providerNeedsKey =
+ connection.mode !== "self_managed" &&
+ !(connection.mode === "agenta" && !!connection.slug) &&
+ vaultLoaded &&
+ !!providerVaultEntry &&
+ !providerVaultEntry.key
+
+ // The "Add custom provider" footer + drawer come from context, same source as the completion picker.
+ // `deployment.isCloud` gates the Provider credentials section's "Use subscription" toggle
+ // (design.md D6) — absent (older OSS providers) reads as not-cloud, i.e. ungated.
+ const {llmProviderConfig, deployment} = useDrillInUI()
+ const isCloud = deployment?.isCloud ?? false
// Harness-filtered model options: the inspect catalog PLUS the vault custom_provider models,
// so a configured Bedrock model is selectable. Empty when the harness publishes none AND the
@@ -237,33 +236,43 @@ export function useModelHarness({
provider?: string | null
mode?: ConnectionMode
slug?: string | null
+ /** A vault-hosted option's own connection kind (`metadata.provider` from
+ * `vaultModelGroups`) — a fallback family source, see `vaultPickedProviderFamily`. */
+ metadataProvider?: string | null
}) => {
const nextModelId = patch.modelId !== undefined ? patch.modelId : modelId
- // A vault-model pick reunites the model with its connection slug.
- const vaultMatch =
- patch.modelId !== undefined
- ? customSecrets.find((s) => (s.models ?? []).includes(nextModelId ?? ""))
- : undefined
- // Provider is always the model FAMILY — a vault match's `provider` is its DEPLOYMENT
- // kind (bedrock/…), which would fail the harness provider check.
+ // Explicit slug wins — the picker threads a vault option's own connection slug through
+ // (see the `SelectLLMProviderBase` onChange below), so we never guess the connection by
+ // model id (duplicate ids can exist across providers/connections). A model switch with
+ // no explicit slug CLEARS the old one rather than keeping it: the backend fails loud on
+ // a provider/slug mismatch when the new model is a standard catalog provider.
+ const nextSlug =
+ patch.slug !== undefined
+ ? patch.slug
+ : patch.modelId !== undefined
+ ? null
+ : connection.slug
+ // Provider is always the model FAMILY — a vault connection's own `provider` is its
+ // DEPLOYMENT kind (bedrock/…), which would fail the harness provider check. For a vault
+ // pick, `vaultPickedProviderFamily` prefers the id-encoded family and only falls back to
+ // the connection's own kind when that kind is ALREADY a plain family (not a deployment
+ // kind); either way, never drop to null while a prior provider exists (guarantees a
+ // model pick never silently clears `llm.provider`).
let nextProvider: string | null
if (patch.provider !== undefined) {
nextProvider = patch.provider
} else if (patch.modelId !== undefined) {
- nextProvider = vaultMatch
- ? familyFromModelId(nextModelId, capabilities)
+ nextProvider = patch.slug
+ ? (vaultPickedProviderFamily(
+ nextModelId,
+ patch.metadataProvider,
+ capabilities,
+ ) ?? connection.provider)
: (providerForModel(capabilities, harnessValue, nextModelId) ??
connection.provider)
} else {
nextProvider = connection.provider
}
- // Explicit slug wins; a vault pick auto-fills; a non-vault pick keeps the current one.
- const nextSlug =
- patch.slug !== undefined
- ? patch.slug
- : vaultMatch?.name
- ? (vaultMatch.name as string)
- : connection.slug
setAgentField(
"llm",
composeModelValue({
@@ -275,27 +284,46 @@ export function useModelHarness({
}),
)
},
- [setAgentField, modelId, connection, llm, capabilities, harnessValue, customSecrets],
+ [setAgentField, modelId, connection, llm, capabilities, harnessValue],
)
+ // Adopt a custom provider created FROM this pane: after the user opens the Configure-provider
+ // drawer via an "Add custom provider" rail row, the first NEW vault connection that appears becomes the
+ // selection — its first model + its connection slug — so the pane reflects what was just added.
+ // Armed per click so a provider created elsewhere (e.g. Settings → Secrets) never steals the model.
+ const knownCustomSecretKeysRef = useRef | null>(null)
+ const adoptNextCustomProviderRef = useRef(false)
+ useEffect(() => {
+ const keys = new Set(customSecrets.map((secret) => secret.id ?? secret.name ?? ""))
+ const known = knownCustomSecretKeysRef.current
+ knownCustomSecretKeysRef.current = keys
+ if (!known || !adoptNextCustomProviderRef.current) return
+ const added = customSecrets.find((secret) => !known.has(secret.id ?? secret.name ?? ""))
+ if (!added) return
+ adoptNextCustomProviderRef.current = false
+ const firstModel = (added.models ?? []).find(Boolean)
+ if (firstModel && added.name) writeModel({modelId: firstModel, slug: added.name})
+ }, [customSecrets, writeModel])
+ const openConfigureProviderAdopting = useMemo(() => {
+ const open = llmProviderConfig?.openConfigureProvider
+ if (!open) return undefined
+ return (kind: string) => {
+ adoptNextCustomProviderRef.current = true
+ open(kind)
+ }
+ }, [llmProviderConfig?.openConfigureProvider])
+
// Model is deliberately NOT cleared on a harness switch that can't reach it: the compatibility
// panel flags it instead, so the user's choice survives (Arda's call; may error at run time).
// Reset a connection mode the new harness disallows; guarded on a non-empty option set so a
- // harness publishing no modes stays permissive. Slug is NOT normalized here (connectionOptions
- // is vault-async; an empty set mid-load would wrongly clear a valid slug).
+ // harness publishing no modes stays permissive.
useEffect(() => {
if (modeOptions.length > 0 && !modeOptions.includes(connection.mode)) {
writeModel({mode: modeOptions[0], slug: null})
}
}, [connection.mode, modeOptions, writeModel])
- // Named connections selectable for the chosen provider under this harness (Agenta-managed).
- const connectionOptions = useMemo(
- () => namedConnectionOptions(vaultSecrets, capabilities, harnessValue, connection.provider),
- [vaultSecrets, capabilities, harnessValue, connection.provider],
- )
-
// Claude permissions (Layer 1, Claude-only): the Claude harness's own permission knobs, the
// first-class `harness.permissions` slice. Shown in Advanced only for the Claude harness.
const claudePermissions = useMemo(() => {
@@ -353,7 +381,6 @@ export function useModelHarness({
})
const hasAdvanced = Boolean(
- props.llm || // Authentication lives in Advanced now
sandboxProps.kind ||
sandboxProps.permissions ||
runnerProps.permissions ||
@@ -373,7 +400,25 @@ export function useModelHarness({
showGroup
options={modelGroups}
value={modelId ?? undefined}
- onChange={(v) => writeModel({modelId: (v as string) ?? null})}
+ onChange={(v, option) => {
+ // A vault-hosted model option carries its own connection slug + kind in
+ // `metadata` (set by `vaultModelGroups`); a catalog option carries neither.
+ // Read them straight off the picked option instead of re-guessing the
+ // connection by model id — duplicate ids across providers/connections would
+ // resolve to the wrong one. The kind is a fallback provider source only (see
+ // `vaultPickedProviderFamily`) for ids that encode no family themselves.
+ const picked = Array.isArray(option) ? option[0] : option
+ const metadata = (
+ picked as
+ | {metadata?: {connectionSlug?: string; provider?: string}}
+ | undefined
+ )?.metadata
+ writeModel({
+ modelId: (v as string) ?? null,
+ slug: metadata?.connectionSlug ?? null,
+ metadataProvider: metadata?.provider ?? null,
+ })
+ }}
disabled={disabled}
placeholder="Select a model…"
className="w-full"
@@ -524,6 +569,23 @@ export function useModelHarness({
)
+ // Provider credentials section: identical in both the capability-aware and flat layouts below,
+ // rendered once and reused so the two branches don't carry a duplicate prop list.
+ const providerCredentialsSection = props.llm ? (
+ writeModel({mode: m})}
+ selectedProviderFamily={selectedProviderFamily}
+ selectedConnectionSlug={connection.slug ?? null}
+ modeOptions={modeOptions}
+ isCloud={isCloud}
+ selfHostingGuideUrl={deployment?.selfHostingGuideUrl}
+ providerNeedsKey={providerNeedsKey}
+ openConfigureProvider={openConfigureProviderAdopting}
+ disabled={disabled}
+ />
+ ) : null
+
// Model & harness drawer body. With inspect capabilities: harness cards + model picker on the
// left (each card owns its model-compat state), version history on the right — same two-panel
// shape as the Advanced drawer. Without capabilities: the plain harness select, single column.
@@ -554,57 +616,23 @@ export function useModelHarness({
}
- title="Model & credentials"
- status={
- !modelId || !selectedKeepsModel || providerNeedsKey ? "warning" : "complete"
- }
+ title="Model"
+ status={!modelId || !selectedKeepsModel ? "warning" : "complete"}
summary={modelId ?? undefined}
summaryCollapsedOnly
>
- setModelTab(v as "model" | "key")}
- >
- {modelTab === "model" ? (
-
- {modelControl}
- {hasInspectModels ? (
-
- Filtered to the models this harness can reach. Selecting a model
- also sets its provider.
-
- ) : null}
-
- ) : providerVaultEntry ? (
-
- ) : (
-
- This model's provider is set up from the model list — use “+ Add
- provider” there to connect its credentials.
+
+ {modelControl}
+ {hasInspectModels ? (
+
+ Filtered to the models this harness can reach. Selecting a model also
+ sets its provider.
- )}
-
+ ) : null}
+
+
+ {providerCredentialsSection}
>
) : (
<>
@@ -620,9 +648,7 @@ export function useModelHarness({
)}
{modelPicker}
- {providerNeedsKey && providerVaultEntry ? (
-
- ) : null}
+ {providerCredentialsSection}
>
)
@@ -641,68 +667,8 @@ export function useModelHarness({
// two-panel split or side panel (which read as out-of-place chrome inside a tab).
const modelHarnessInline =
{modelHarnessControls}
- // Authentication (credential source) — moved out of Model & harness into Advanced. The
- // credential-source axis reads as the drawer's shared `[rail | content]`: mode toggle on the
- // left, the active mode's description + (Agenta-managed) connection picker on the right.
- const authDescription =
- connection.mode === "agenta"
- ? "Agenta supplies the credential from this project's vault — the default provider key, or a named connection you pick below."
- : "The harness signs in itself (an environment variable or a prior OAuth login). Agenta injects no credential."
- const authConnectionField =
- connection.mode === "agenta" ? (
-
-
- ) : null
- const authControls = props.llm ? (
- modeOptions.length > 0 ? (
- ({
- value: m,
- label: m === "agenta" ? "Agenta-managed" : "Self-managed",
- }))}
- value={connection.mode}
- onChange={(m) => writeModel({mode: m as ConnectionMode})}
- >
-
- {authDescription}
-
- {authConnectionField}
-
- ) : (
-
-
- {authDescription}
-
- {authConnectionField}
-
- )
- ) : null
-
- // Advanced header summary: auth mode + sandbox, so the collapsed header still conveys state.
- const advancedSummary =
- [
- props.llm ? (connection.mode === "agenta" ? "Agenta-managed" : "Self-managed") : null,
- sandbox.kind ? `Sandbox: ${String(sandbox.kind)}` : null,
- ]
- .filter(Boolean)
- .join(" · ") || undefined
+ // Advanced header summary: sandbox only now — mode UI moved to the Provider credentials section.
+ const advancedSummary = sandbox.kind ? `Sandbox: ${String(sandbox.kind)}` : undefined
// Advanced drawer body: two panels like Model & harness (settings left, version history right).
const hasExecutionGroup = Boolean(sandboxProps.kind || sandboxProps.permissions)
@@ -712,33 +678,11 @@ export function useModelHarness({
// Shared Advanced controls, rendered by both the wide drawer body and the tabs-inline body.
// Each group is a `ConfigAccordionSection` (the shared drawer section shell used by the trigger
// and tools drawers); inside, configuration reads as the drawer's `[rail | content]` rhythm via
- // `SectionRail` (Authentication mode) and `RailField` (labelled control rows).
+ // `SectionRail` (mode groups) and `RailField` (labelled control rows).
const advancedControls = (
<>
{buildKitSection}
- {authControls ? (
- }
- title="Authentication"
- summary={
- props.llm
- ? connection.mode === "agenta"
- ? "Agenta-managed"
- : "Self-managed"
- : undefined
- }
- summaryCollapsedOnly
- >
-
- Where the model credential comes from when this agent runs.
-
- {authControls}
-
- ) : null}
-
{hasExecutionGroup ? (
d.toLowerCase() === kind)
+ }
+ const providers = allowedProviders(capabilities, harness)
+ return providers.includes("*") || providers.some((p) => p.toLowerCase() === kind)
+}
+
/**
* Grouped model options contributed by the vault's custom_provider connections, so a connection's
- * own models (e.g. a Bedrock connection's `eu.anthropic.claude-haiku-4-5`) are selectable in the
- * model picker — not just the harness's static catalog. Filtered to connections whose provider the
- * harness can consume; each group carries its connection slug in option metadata so picking a model
- * can reunite it with its agenta-managed credential. Skips connections with no models.
+ * own models (e.g. a Bedrock connection's `eu.anthropic.claude-haiku-4-5`, or a second named
+ * `openai`-kind connection's own models) are selectable in the model picker — not just the
+ * harness's static catalog. Filtered to connections whose kind the harness can reach (see
+ * `harnessReachesCustomProviderKind`); each group carries its connection slug in option metadata so
+ * picking a model can reunite it with its agenta-managed credential. Skips connections with no
+ * models.
*/
export function vaultModelGroups(
secrets: VaultModelSource[] | null | undefined,
@@ -375,24 +415,14 @@ export function vaultModelGroups(
harness: string | null | undefined,
): ModelOptionGroup[] {
if (!secrets?.length) return []
- // A custom_provider's `provider` field is its deployment kind (bedrock/vertex_ai/azure/custom),
- // gated against the harness's consumable deployments — NOT its provider families.
- const consumable = allowedDeployments(capabilities, harness)
- const anyDeployment = consumable.includes("*")
const groups: ModelOptionGroup[] = []
for (const secret of secrets) {
const slug = secret.name?.trim()
- const deployment = secret.provider?.toLowerCase() || null
+ const kind = secret.provider?.toLowerCase() || null
const models = (secret.models ?? []).filter(Boolean)
if (!slug || !models.length) continue
- if (
- !anyDeployment &&
- deployment &&
- !consumable.some((d) => d.toLowerCase() === deployment)
- ) {
- continue
- }
+ if (kind && !harnessReachesCustomProviderKind(capabilities, harness, kind)) continue
groups.push({
label: secret.name ?? slug,
options: models.map((id) => ({
@@ -404,40 +434,3 @@ export function vaultModelGroups(
}
return groups
}
-
-/**
- * Named connections selectable for a provider under a harness, from the vault list. Only
- * custom-provider secrets carry a connection name (the slug the resolver matches on); standard
- * provider keys are the implicit project default and are not listed here. Filtered to the chosen
- * provider (case-insensitive) and, when no provider is chosen, to the harness's reachable
- * providers.
- */
-export function namedConnectionOptions(
- secrets: VaultConnectionEntry[] | null | undefined,
- capabilities: HarnessCapabilitiesMap | null | undefined,
- harness: string | null | undefined,
- provider: string | null | undefined,
-): ConnectionOption[] {
- if (!secrets?.length) return []
- const reachable = allowedProviders(capabilities, harness)
- const anyProvider = reachable.includes("*")
- const target = provider?.toLowerCase() || null
-
- const out: ConnectionOption[] = []
- const seen = new Set()
- for (const secret of secrets) {
- if (secret.type !== "custom_provider") continue
- const slug = secret.name?.trim()
- if (!slug || seen.has(slug)) continue
- const secretProvider = secret.provider?.toLowerCase() || null
- if (target) {
- if (secretProvider !== target) continue
- } else if (!anyProvider && secretProvider) {
- // No provider chosen yet: keep only connections the harness can reach.
- if (!reachable.some((p) => p.toLowerCase() === secretProvider)) continue
- }
- seen.add(slug)
- out.push({label: slug, value: slug})
- }
- return out
-}
diff --git a/web/packages/agenta-entity-ui/src/drawers/shared/SectionRail.tsx b/web/packages/agenta-entity-ui/src/drawers/shared/SectionRail.tsx
index 9972b3bb09..0ce1409ee1 100644
--- a/web/packages/agenta-entity-ui/src/drawers/shared/SectionRail.tsx
+++ b/web/packages/agenta-entity-ui/src/drawers/shared/SectionRail.tsx
@@ -69,7 +69,7 @@ export function SectionRail({
: "!justify-start"
} ${
active
- ? "!bg-[var(--ag-colorPrimaryBg)] !font-medium !text-[var(--ag-colorPrimary)]"
+ ? "!bg-[var(--ag-colorFillSecondary)] !font-semibold !text-[var(--ag-colorText)]"
: "!text-[var(--ag-colorTextSecondary)] hover:!bg-[var(--ag-colorFillTertiary)] hover:!text-[var(--ag-colorText)]"
}`}
>
diff --git a/web/packages/agenta-entity-ui/src/secretProvider/CustomProviderForm.tsx b/web/packages/agenta-entity-ui/src/secretProvider/CustomProviderForm.tsx
new file mode 100644
index 0000000000..1fa1a7f0ba
--- /dev/null
+++ b/web/packages/agenta-entity-ui/src/secretProvider/CustomProviderForm.tsx
@@ -0,0 +1,367 @@
+import React, {useEffect, useMemo, useState} from "react"
+
+import {
+ PROVIDER_AUTH_REQUIREMENTS,
+ PROVIDER_FIELDS,
+ PROVIDER_KINDS,
+ PROVIDER_LABELS,
+ STANDARD_PROVIDER_KINDS,
+ useVaultSecret,
+ type ProviderFieldConfig,
+} from "@agenta/entities/secret"
+import type {LlmProvider} from "@agenta/shared/types"
+import {isSlugInputValid} from "@agenta/shared/utils"
+import {LabelInput} from "@agenta/ui"
+import {SelectLLMProviderBase, capitalize, type ProviderGroup} from "@agenta/ui/select-llm-provider"
+import {Plus, WarningCircle} from "@phosphor-icons/react"
+import {Button, Form, Input, Typography} from "antd"
+import type {FormInstance} from "antd"
+import {useWatch} from "antd/lib/form/Form"
+
+import ModelNameInput from "./ModelNameInput"
+
+const {Text} = Typography
+
+export interface CustomProviderFormProps {
+ selectedProvider?: LlmProvider | null
+ /** Pre-selects the provider kind for a NEW provider (editing ignores it — `selectedProvider`
+ * already carries its own kind). */
+ initialProviderKind?: string
+ form: FormInstance
+ onClose: () => void
+}
+
+/** Render control based on field.attributes */
+const renderControl = (field: ProviderFieldConfig, isRequired?: boolean) => {
+ const a = field.attributes
+
+ if (!a || a.kind === "text") {
+ // Keep your existing single-line input
+ return (
+
+ )
+ }
+
+ if (a.kind === "textarea") {
+ return (
+