From 7b0f11fc7b2370c92558e9d2ce4500efca50192d Mon Sep 17 00:00:00 2001
From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com>
Date: Fri, 23 Oct 2026 06:08:27 +0530
Subject: [PATCH] feat(connectors): no guest on page open, connector sign-in
offer (#143248)
* feat(connectors): list hosted apps without creating a guest identity
Opening the desktop Connectors page used to create a Nous guest identity:
connectors.list, connectors.catalog, connectors.accounts and
connectors.policy.get each created one before answering, because the
Portal's catalog route needs a token. A user who only looked at the page
got an identity anyway.
While there is no identity yet (and one may still be created), those four
reads now answer without one: the catalog from a snapshot that ships with
Hermes, an empty connector list, no accounts and no rules. Connecting an
app still creates the identity, as before.
scripts/refresh_connector_catalog.py rewrites the snapshot from a
signed-in install. It leaves logos out: no client draws them.
* feat(free-tier): sign-in offer for guests who connected apps
Every free-tier sign-in offer keyed on the welcome trial, so a user on
their own model key who connected apps through a guest identity never saw
one.
The offer record now tracks two offers on the same schedule. `trial` is
unchanged. `connector` is for a guest without the trial that has connected
an app: each app that finishes connecting sets `has_connected_app` on the
guest state (so it dies with the guest) and counts as a finished task.
free_tier.status (still a pure local read) adds connector_nudge_due_in, and
free_tier.claim_nudge takes a `kind`.
The desktop reads the connector offer whether or not the trial is
available, re-reads the status after a turn or a page connect, and shows
connector copy instead of the free-allowance copy. After a page connect
settles it also re-reads the catalog and rules, so the bundled catalog
from before the identity existed is replaced by the account's own.
* fix(connectors): point the sign-in hint at `hermes auth upgrade`
`hermes connectors` told users to run `hermes login`, which no longer
exists. Both hints now say "Run `hermes auth upgrade` to sign in."
* feat(connectors): read the public catalog with no token; one guest rule per entry point
The bundled catalog snapshot listed apps a new guest cannot connect and
went stale whenever the catalog changed. With no token, the Connectors
page now reads the Nous Portal's public catalog route
(GET /api/v1/connectors/catalog/public), sent with no Authorization and
parsed with the existing catalog model. Once a token exists the page reads
the signed-in route, as before.
Whether an entry point may create the guest identity is now declared once
per entry point instead of checked inside handlers:
- managed_client(creates_guest=...) is required: connect (page, agent,
card retry) and the explicit lists (CLI `hermes connectors list`, agent
manage_connections status/display) pass True, unchanged.
- account RPCs declare creates_guest on _account_method: catalog,
accounts and policy.get never create one; tools and the account writes
still do.
- the page's connectors.list never creates one; connect does.
With no token the page reads give true answers: public catalog, empty
connected list, no accounts. The invented "unrestricted" policy is gone:
the desktop asks for rules only once the list has rows, which needs a
token, and policy.get without one refuses with sign-in-required.
Deleted: the bundled snapshot, its refresh script, and the no-identity
stubs.
* fix(connectors): CI and review round: older backends, outside connects, link connects, guest opt-out
- CI: test_legacy_fallback_ticket_profile_verbs faked managed_client with
no arguments and gave the secondary profile no token, so the page list
(now token-gated, creates_guest=False) never reached it. The fake takes
the new keyword and the profile holds a token.
- Older backends: a trial claim sends free_tier.claim_nudge with no
params again. An older backend rejects the unknown `kind` key and would
have dropped the trial offer; the new backend defaults to the trial.
Only the connector claim sends `kind`.
- Outside connects: while the page's list is empty (no identity), every
finished turn or connect re-reads the list and catalog. An agent connect
in a chat then moves the page to the signed-in routes and its rules.
- Link connects: an agent status read that sees a connected app on a
guest without the flag arms the connector offer, so messaging and
one-shot connects (no operation watcher) count too.
- Opt-out: the connector offer requires nous.guest to be allowed.
* fix(connectors): the Connectors page list arms the connector offer too
A guest whose apps were connected by an older Hermes has no has_connected_app flag, and only the operation watcher or the agent's status read set it. The page's connectors.list now passes its rows to note_connected_apps_seen, which owns the any-connected check for both list reads.
* fix(connectors): review round 3: accounts after an outside connect, retryable offer record
- An identity created outside the page (an agent connect in a chat) now also re-reads the accounts cached while there was none, so the new account row, rename and remove show up.
- note_app_connected records the connector task before it sets has_connected_app. The flag ends list-read sightings, so a failed schedule write no longer leaves the offer unarmed for good.
---
.../connectors/data/account-operations.ts | 9 +-
.../connectors/data/queries-rules.test.tsx | 82 ++++++++
.../capabilities/connectors/data/queries.ts | 16 ++
.../hooks/desktop-onboarding-metrics.test.ts | 2 +-
.../free-tier/sign-in-dialog.test.tsx | 23 ++-
.../components/free-tier/sign-in-dialog.tsx | 5 +-
apps/desktop/src/i18n/en_notices.ts | 9 +-
apps/desktop/src/i18n/overlay-gaps.json | 2 +
apps/desktop/src/i18n/types_notices.ts | 2 +
.../desktop/src/store/free-tier-offer.test.ts | 20 +-
.../src/store/free-tier-sign-in.test.ts | 4 +-
apps/desktop/src/store/free-tier-sign-in.ts | 68 +++++--
apps/desktop/src/types/hermes.ts | 2 +
apps/shared/src/gateway-contract.generated.ts | 7 +-
apps/shared/src/gateway-contract.openrpc.json | 42 +++-
hermes_cli/connectors_cmd.py | 6 +-
hermes_cli/free_tier_offer.py | 94 ++++++---
locales/_keys.desktop.json | 2 +
tests/hermes_cli/test_free_tier_offer.py | 20 ++
tests/tools/test_connector_wire_identity.py | 3 +-
.../test_connectors_page_identity.py | 188 ++++++++++++++++++
...st_legacy_fallback_ticket_profile_verbs.py | 4 +-
tools/connectors/account.py | 2 +-
tools/connectors/managed.py | 37 +++-
tools/connectors/portal/client.py | 16 +-
tools/connectors/run.py | 2 +-
.../contracts/config_free_tier_control.py | 8 +-
tui_gateway/methods_connectors.py | 20 +-
tui_gateway/methods_connectors_account.py | 22 +-
tui_gateway/methods_free_tier.py | 15 +-
.../docs/user-guide/features/tool-search.md | 9 +-
31 files changed, 642 insertions(+), 99 deletions(-)
create mode 100644 apps/desktop/src/app/capabilities/connectors/data/queries-rules.test.tsx
create mode 100644 tests/tui_gateway/test_connectors_page_identity.py
diff --git a/apps/desktop/src/app/capabilities/connectors/data/account-operations.ts b/apps/desktop/src/app/capabilities/connectors/data/account-operations.ts
index 4e7bdbc8ea..7351a1b464 100644
--- a/apps/desktop/src/app/capabilities/connectors/data/account-operations.ts
+++ b/apps/desktop/src/app/capabilities/connectors/data/account-operations.ts
@@ -8,6 +8,7 @@ import { atom } from 'nanostores'
import type { ProfileScope } from '@/hermes'
import { type ConnectionTarget, parseConnectionTarget } from '@/store/connection-request'
+import { noteFreeTierAppConnected } from '@/store/free-tier-sign-in'
import { activeGatewayConnectionId } from '@/store/gateway'
import { normalizeProfileKey } from '@/store/profile'
@@ -212,5 +213,11 @@ function refetchOnSettle(operation: AccountOperation): void {
return
}
- invalidateConnectors(operation.scope, 'list', 'accounts')
+ // The first connect can create the identity the page opened without, so its bundled catalog and
+ // empty rules are re-read too.
+ invalidateConnectors(operation.scope, 'list', 'accounts', 'catalog', 'policy')
+
+ if (operation.targets.some(target => target.state === 'connected')) {
+ noteFreeTierAppConnected()
+ }
}
diff --git a/apps/desktop/src/app/capabilities/connectors/data/queries-rules.test.tsx b/apps/desktop/src/app/capabilities/connectors/data/queries-rules.test.tsx
new file mode 100644
index 0000000000..543d3af759
--- /dev/null
+++ b/apps/desktop/src/app/capabilities/connectors/data/queries-rules.test.tsx
@@ -0,0 +1,82 @@
+import { QueryClientProvider } from '@tanstack/react-query'
+import { cleanup, renderHook, waitFor } from '@testing-library/react'
+import type { ReactNode } from 'react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { queryClient } from '@/lib/query-client'
+import { noteFreeTierTurnComplete } from '@/store/free-tier-sign-in'
+import { setPrimaryGateway, setPrimaryGatewayConnectionId } from '@/store/gateway'
+
+import { useHostedConnectors } from './queries'
+
+const row = { connected: false, connector: 'notion', enabled: true, gateway_disabled_tools: [] }
+
+function gateway(listed: object[]) {
+ const request = vi.fn(
+ async (method: string) =>
+ ({
+ 'connectors.accounts': { accounts: [] },
+ 'connectors.catalog': { connectors: [] },
+ 'connectors.list': { available: true, connectors: [...listed] },
+ 'connectors.policy.get': { effective: { mode: 'unrestricted' }, layers: [] }
+ })[method]
+ )
+
+ // SAFETY: the page's queries call only `request` on the primary socket.
+ setPrimaryGateway({ request } as never)
+ setPrimaryGatewayConnectionId('connection-1')
+
+ return request
+}
+
+// The page invalidates through the app's one query client, so the hook runs on it too.
+function mount() {
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ )
+
+ return renderHook(() => useHostedConnectors('default'), { wrapper })
+}
+
+const asked = (request: ReturnType, method: string) =>
+ request.mock.calls.some(([name]) => name === method)
+
+afterEach(() => {
+ cleanup()
+ queryClient.clear()
+})
+
+describe('connector rules on the Connectors page', () => {
+ it('are not asked for while the list is empty (no identity yet)', async () => {
+ const request = gateway([])
+ const { result } = mount()
+
+ await waitFor(() => expect(result.current.phase).toBe('ready'))
+ await waitFor(() => expect(asked(request, 'connectors.catalog')).toBe(true))
+
+ expect(asked(request, 'connectors.policy.get')).toBe(false)
+ expect(result.current.rulesFailed).toBe(false)
+ })
+
+ it('are asked for once the gateway lists apps', async () => {
+ const request = gateway([row])
+
+ mount()
+
+ await waitFor(() => expect(asked(request, 'connectors.policy.get')).toBe(true))
+ })
+
+ it('are asked for once an identity appears outside the page (an agent connect in a chat)', async () => {
+ const listed: object[] = []
+ const request = gateway(listed)
+ const { result } = mount()
+
+ await waitFor(() => expect(result.current.phase).toBe('ready'))
+ listed.push(row)
+ noteFreeTierTurnComplete()
+
+ await waitFor(() => expect(asked(request, 'connectors.policy.get')).toBe(true))
+ // The accounts read cached while there was no identity is re-read too, or the new account stays hidden.
+ expect(request.mock.calls.filter(([name]) => name === 'connectors.accounts')).toHaveLength(2)
+ })
+})
diff --git a/apps/desktop/src/app/capabilities/connectors/data/queries.ts b/apps/desktop/src/app/capabilities/connectors/data/queries.ts
index 593747b4cb..0355e7c3f1 100644
--- a/apps/desktop/src/app/capabilities/connectors/data/queries.ts
+++ b/apps/desktop/src/app/capabilities/connectors/data/queries.ts
@@ -7,6 +7,7 @@ import type { ProfileScope } from '@/hermes'
import { translateNow } from '@/i18n'
import { isMissingRpcMethod, isOutOfSyncRpcParams } from '@/lib/gateway-rpc'
import { queryClient } from '@/lib/query-client'
+import { $freeTierTurnCompleted } from '@/store/free-tier-sign-in'
import { notifyError } from '@/store/notifications'
import { hostedPhase } from '../derive'
@@ -102,8 +103,23 @@ export function useHostedConnectors(scope: ProfileScope): HostedConnectorsView {
queryKey: connectorsAccountsQueryKey(scope)
})
+ // Rules exist only for an identity. The page list is empty until one does (no token, no gateway
+ // read), so rules are asked for once it has rows.
+ const listed = (list.data?.connectors.length ?? 0) > 0
+
+ // The identity can appear outside this page (an agent connect in a chat). Until the list has rows,
+ // every finished turn or connect re-reads the list, catalog and accounts, which then move to the signed-in routes.
+ useEffect(
+ () =>
+ listed
+ ? undefined
+ : $freeTierTurnCompleted.listen(() => invalidateConnectors(scope, 'list', 'catalog', 'accounts')),
+ [listed, scope]
+ )
+
const policy = useQuery({
...read('policy'),
+ enabled: listed,
queryFn: () => connectorPolicy(scope),
queryKey: connectorsPolicyQueryKey(scope)
})
diff --git a/apps/desktop/src/app/contrib/hooks/desktop-onboarding-metrics.test.ts b/apps/desktop/src/app/contrib/hooks/desktop-onboarding-metrics.test.ts
index 65f981f460..0fb01f4c83 100644
--- a/apps/desktop/src/app/contrib/hooks/desktop-onboarding-metrics.test.ts
+++ b/apps/desktop/src/app/contrib/hooks/desktop-onboarding-metrics.test.ts
@@ -30,7 +30,7 @@ async function walk(...states: FreeTierSignInState[]): Promise {
}
const CLOSED: FreeTierSignInState = { status: 'closed' }
-const OFFER: FreeTierSignInState = { status: 'offer' }
+const OFFER: FreeTierSignInState = { kind: 'trial', status: 'offer' }
const SETTING_UP: FreeTierSignInState = { minting: false, status: 'setting_up' }
beforeEach(() => {
diff --git a/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx b/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx
index ab598eec74..75f84794ee 100644
--- a/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx
+++ b/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx
@@ -185,14 +185,14 @@ describe('FreeTierSignInDialog', () => {
$freeTierStatus.set({ ...dueNow, nudge_due_in: 0 })
})
- expect(requestGateway).not.toHaveBeenCalledWith('free_tier.claim_nudge')
+ expect(requestGateway).not.toHaveBeenCalledWith('free_tier.claim_nudge', undefined)
await act(async () => {
setOnboardingSurfaceActive('onboarding', false)
})
await waitFor(() => expect(screen.getByText('Keep going with Hermes')).toBeTruthy())
- expect(requestGateway).toHaveBeenCalledWith('free_tier.claim_nudge')
+ expect(requestGateway).toHaveBeenCalledWith('free_tier.claim_nudge', undefined)
})
it('a finished free-tier turn re-reads the status, and a due offer opens with no user action', async () => {
@@ -212,7 +212,7 @@ describe('FreeTierSignInDialog', () => {
it('Sign in continues into the normal sign-in flow', async () => {
await renderDialog()
- await act(async () => $freeTierSignIn.set({ status: 'offer' }))
+ await act(async () => $freeTierSignIn.set({ kind: 'trial', status: 'offer' }))
await act(async () => screen.getByRole('button', { name: 'Sign in' }).click())
@@ -221,12 +221,27 @@ describe('FreeTierSignInDialog', () => {
it('Not now closes the offer', async () => {
await renderDialog()
- await act(async () => $freeTierSignIn.set({ status: 'offer' }))
+ await act(async () => $freeTierSignIn.set({ kind: 'trial', status: 'offer' }))
await act(async () => screen.getByRole('button', { name: 'Not now' }).click())
expect($freeTierSignIn.get()).toEqual({ status: 'closed' })
expect(screen.queryByText('Keep going with Hermes')).toBeNull()
})
+
+ it('an app connected without the trial: claims the connector offer and shows its copy', async () => {
+ await renderDialog()
+ const ownKey = { ...dueNow, available: false, connector_nudge_due_in: null }
+ await act(async () => $freeTierStatus.set(ownKey))
+ requestGateway.mockImplementation(async (method?: string) =>
+ method === 'free_tier.claim_nudge' ? { claimed: true } : { ...ownKey, connector_nudge_due_in: 0 }
+ )
+
+ await act(async () => noteFreeTierTurnComplete())
+
+ await waitFor(() => expect(screen.getByText('Keep your connected apps')).toBeTruthy())
+ expect(requestGateway).toHaveBeenCalledWith('free_tier.claim_nudge', { kind: 'connector' })
+ expect(screen.queryByText('Keep going with Hermes')).toBeNull()
+ })
})
})
diff --git a/apps/desktop/src/components/free-tier/sign-in-dialog.tsx b/apps/desktop/src/components/free-tier/sign-in-dialog.tsx
index 8524abb288..7bd9537d03 100644
--- a/apps/desktop/src/components/free-tier/sign-in-dialog.tsx
+++ b/apps/desktop/src/components/free-tier/sign-in-dialog.tsx
@@ -145,7 +145,10 @@ export function FreeTierSignInDialog({ onSelectModel }: FreeTierSignInDialogProp
!open && closeFreeTierSignIn()} open>
{state.status === 'offer' && (
-
+
closeFreeTierSignIn()} size="sm" type="button" variant="text">
{copy.offer.notNow}
diff --git a/apps/desktop/src/i18n/en_notices.ts b/apps/desktop/src/i18n/en_notices.ts
index 5e00deff5b..34d36eb096 100644
--- a/apps/desktop/src/i18n/en_notices.ts
+++ b/apps/desktop/src/i18n/en_notices.ts
@@ -35,5 +35,12 @@ export const enNotices = {
message:
'Hermes can keep your computer awake while a task runs. The screen still turns off, and on battery your computer may still sleep.',
action: 'Keep awake while working'
+ },
+ connectorOffer: {
+ heading: 'Keep your connected apps',
+ body: 'Sign in with a free Nous account to keep the apps you connected, like Gmail or Slack, on your account.'
}
-} satisfies Pick
+} satisfies Pick<
+ Translations,
+ 'remoteDisplayBanner' | 'butterbar' | 'promptNotices' | 'keepAwakeOffer' | 'connectorOffer'
+>
diff --git a/apps/desktop/src/i18n/overlay-gaps.json b/apps/desktop/src/i18n/overlay-gaps.json
index 4e0ec24767..9c3d5e06da 100644
--- a/apps/desktop/src/i18n/overlay-gaps.json
+++ b/apps/desktop/src/i18n/overlay-gaps.json
@@ -382,6 +382,8 @@
"composer.wakeWordListening": ["ar"],
"composer.wakeWordOff": ["ar"],
"composer.wakeWordPausedVoice": ["ar"],
+ "connectorOffer.body": ["ar","de","es","fr","ja","ru","zh","zh-hant"],
+ "connectorOffer.heading": ["ar","de","es","fr","ja","ru","zh","zh-hant"],
"connectors.authorizedToolsUnavailable": ["ar","ja","ru","zh-hant"],
"connectors.cancel": ["ar","ja","ru","zh-hant"],
"connectors.checking": ["ar","ja","ru","zh","zh-hant"],
diff --git a/apps/desktop/src/i18n/types_notices.ts b/apps/desktop/src/i18n/types_notices.ts
index a3df684d18..6b256efa96 100644
--- a/apps/desktop/src/i18n/types_notices.ts
+++ b/apps/desktop/src/i18n/types_notices.ts
@@ -32,6 +32,8 @@ export interface NoticeTranslations {
/** Once per computer, after a timed sleep interrupted a turn with keep-awake off. */
keepAwakeOffer: { title: string; message: string; action: string }
+ /** The sign-in offer after apps connect on the free tier without the welcome trial. */
+ connectorOffer: { heading: string; body: string }
}
export interface SessionCommandNotices {
diff --git a/apps/desktop/src/store/free-tier-offer.test.ts b/apps/desktop/src/store/free-tier-offer.test.ts
index d2acfae494..000441d7dd 100644
--- a/apps/desktop/src/store/free-tier-offer.test.ts
+++ b/apps/desktop/src/store/free-tier-offer.test.ts
@@ -90,7 +90,7 @@ describe('sign-in offer after a finished task', () => {
await vi.advanceTimersByTimeAsync(0)
expect(claims(requestGateway)).toBe(1)
- expect($freeTierSignIn.get()).toEqual({ status: 'offer' })
+ expect($freeTierSignIn.get()).toEqual({ kind: 'trial', status: 'offer' })
})
it('due later: waits out nudge_due_in, re-reads the status, then claims', async () => {
@@ -107,7 +107,23 @@ describe('sign-in offer after a finished task', () => {
expect(requestGateway).toHaveBeenCalledWith('free_tier.status')
expect(claims(requestGateway)).toBe(1)
- expect($freeTierSignIn.get()).toEqual({ status: 'offer' })
+ expect($freeTierSignIn.get()).toEqual({ kind: 'trial', status: 'offer' })
+ })
+
+ it('an older backend that rejects unknown params still serves the trial offer', async () => {
+ const requestGateway = vi.fn(async (method: string, params?: Record) => {
+ if (params !== undefined) {
+ throw new Error('unknown param')
+ }
+
+ return method === 'free_tier.claim_nudge' ? { claimed: true } : status(null)
+ // SAFETY: the code under test asks only these two methods and reads exactly these shapes back.
+ }) as ReturnType & FreeTierRequester
+
+ syncFreeTierOffer(status(0), requestGateway)
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect($freeTierSignIn.get()).toEqual({ kind: 'trial', status: 'offer' })
})
it('a claim another window won leaves the dialog closed', async () => {
diff --git a/apps/desktop/src/store/free-tier-sign-in.test.ts b/apps/desktop/src/store/free-tier-sign-in.test.ts
index ee326587e6..0c80f5e84a 100644
--- a/apps/desktop/src/store/free-tier-sign-in.test.ts
+++ b/apps/desktop/src/store/free-tier-sign-in.test.ts
@@ -218,9 +218,9 @@ describe('free-tier sign-in long-polls', () => {
showFreeTierSignInResult({ handoff_status: null, session_id: 's', status: 'approved' })
expect($freeTierSignIn.get()).toEqual({ status: 'closed' })
- $freeTierSignIn.set({ status: 'offer' })
+ $freeTierSignIn.set({ kind: 'trial', status: 'offer' })
showFreeTierSignInResult({ handoff_status: claimed, session_id: 's', status: 'approved' })
- expect($freeTierSignIn.get()).toEqual({ status: 'offer' })
+ expect($freeTierSignIn.get()).toEqual({ kind: 'trial', status: 'offer' })
$freeTierSignIn.set({ status: 'closed' })
showFreeTierSignInResult({
diff --git a/apps/desktop/src/store/free-tier-sign-in.ts b/apps/desktop/src/store/free-tier-sign-in.ts
index 2cb0e14d97..06b8c618ed 100644
--- a/apps/desktop/src/store/free-tier-sign-in.ts
+++ b/apps/desktop/src/store/free-tier-sign-in.ts
@@ -14,7 +14,7 @@ import { LONG_POLL_MIN_GAP_MS, pause, restOfLongPollGap } from '@/lib/long-poll'
import { gatewayActivationEpoch } from '@/store/gateway'
import type { AppHandoffStatus, FreeTierStatus, OAuthPollResponse } from '@/types/hermes'
-import { $freeTierStatus, type FreeTierRequester, NOUS_PROVIDER_ID, refreshFreeTierStatus } from './free-tier'
+import { type FreeTierRequester, NOUS_PROVIDER_ID, refreshFreeTierStatus } from './free-tier'
import { $localModelOfferPending } from './local-model-offer'
import { afterOnboardingSurfaceClear, onboardingSurfaceActive } from './onboarding-presence'
@@ -27,6 +27,8 @@ import { afterOnboardingSurfaceClear, onboardingSurfaceActive } from './onboardi
* not name lands on `error`, which carries its own message. */
export type FreeTierSignInFailure = 'busy' | 'error' | 'timed_out' | 'unavailable' | 'unreachable'
+export type FreeTierOfferKind = 'connector' | 'trial'
+
export type FreeTierSignInState =
| { status: 'already_signed_in' }
| { status: 'closed' }
@@ -34,10 +36,10 @@ export type FreeTierSignInState =
// A "please open the dialog" request from an entry point that has no gateway
// requester of its own. The mounted host picks it up and drives the flow.
| { status: 'requested' }
- // The "keep going" offer after a finished task (backend-timed, repeating with
- // back-off). Unlike `requested` it waits for the user's Sign in before
- // starting the transfer.
- | { status: 'offer' }
+ // A sign-in offer the backend timed (repeating with back-off): `trial` after a
+ // finished free-tier task, `connector` after apps connect without the trial.
+ // Unlike `requested` it waits for the user's Sign in before starting the transfer.
+ | { kind: FreeTierOfferKind; status: 'offer' }
// `handoff`: the free tier's apps moving to this account (null when the
// sign-in replaced no free tier). Followed while it is still retrying.
// `scope`: the profile the sign-in ran for when it is not the active one,
@@ -138,27 +140,45 @@ function clearOfferTimer() {
}
}
-// Bumped when a turn completes on the free tier. The backend records the finished task before it
-// sends message.complete, so the dialog owner's re-read on this signal learns when the next offer is due.
+// Bumped when a turn completes or an app connects. The backend records the finished task (or the
+// connected app) before it reports either, so the dialog owner's re-read on this signal learns when
+// the next offer is due. Not gated on the cached status: a turn's own connect can create the identity
+// the cache predates, and the re-read is local and zero-network.
export const $freeTierTurnCompleted = atom(0)
export function noteFreeTierTurnComplete() {
- if ($freeTierStatus.get()?.available) {
- $freeTierTurnCompleted.set($freeTierTurnCompleted.get() + 1)
- }
+ $freeTierTurnCompleted.set($freeTierTurnCompleted.get() + 1)
}
+/** An app connected from the Connectors page, outside any turn. */
+export const noteFreeTierAppConnected = noteFreeTierTurnComplete
+
/** Stop a pending offer timer (the dialog owner unmounted). */
export function stopFreeTierOffer() {
clearOfferTimer()
}
+// The trial offer rides on the trial being live; the connector offer is for users without it, so
+// the backend owes at most one of the two.
+function dueOffer(status: FreeTierStatus | null): { dueIn: number; kind: FreeTierOfferKind } | null {
+ const trial = status?.available ? (status.nudge_due_in ?? null) : null
+
+ if (trial !== null) {
+ return { dueIn: trial, kind: 'trial' }
+ }
+
+ const connector = status?.connector_nudge_due_in ?? null
+
+ return connector === null ? null : { dueIn: connector, kind: 'connector' }
+}
+
/**
- * Act on the backend's `nudge_due_in` (it alone decides when the next
- * offer is due). Later: one timer that re-reads the status, whose fresh answer
- * comes back through here. Due now: claim it, and only the one caller the
- * backend says `claimed` opens the offer. Called on every status read and when
- * guided onboarding leaves the screen; a guarded call simply waits for the next.
+ * Act on the backend's `nudge_due_in` / `connector_nudge_due_in` (it alone
+ * decides when the next offer is due). Later: one timer that re-reads the
+ * status, whose fresh answer comes back through here. Due now: claim it, and
+ * only the one caller the backend says `claimed` opens the offer. Called on
+ * every status read and when guided onboarding leaves the screen; a guarded
+ * call simply waits for the next.
*/
export function syncFreeTierOffer(status: FreeTierStatus | null, requestGateway: FreeTierRequester) {
// A re-sync on the same read (an onboarding change) keeps its running timer, so it is not pushed back.
@@ -167,12 +187,14 @@ export function syncFreeTierOffer(status: FreeTierStatus | null, requestGateway:
}
clearOfferTimer()
- const dueIn = status?.available ? (status.nudge_due_in ?? null) : null
+ const due = dueOffer(status)
- if (dueIn === null) {
+ if (due === null) {
return
}
+ const { dueIn, kind } = due
+
if (dueIn > 0) {
const isCurrent = sameGatewayRoute()
offerTimerFor = status
@@ -188,7 +210,7 @@ export function syncFreeTierOffer(status: FreeTierStatus | null, requestGateway:
return
}
- void claimFreeTierOffer(requestGateway)
+ void claimFreeTierOffer(requestGateway, kind)
}
// The local-model offer after onboarding goes first; the sign-in offer is claimed once it is answered.
@@ -198,7 +220,7 @@ const offerBlocked = () =>
$localModelOfferPending.get() ||
$freeTierSignIn.get().status !== 'closed'
-async function claimFreeTierOffer(requestGateway: FreeTierRequester) {
+async function claimFreeTierOffer(requestGateway: FreeTierRequester, kind: FreeTierOfferKind) {
if (offerBlocked()) {
return
}
@@ -208,7 +230,9 @@ async function claimFreeTierOffer(requestGateway: FreeTierRequester) {
let claimed: boolean
try {
- claimed = (await requestGateway('free_tier.claim_nudge'))?.claimed === true
+ // A trial claim sends no `kind`: an older backend rejects the unknown key, and defaults to the trial.
+ const params = kind === 'trial' ? undefined : { kind }
+ claimed = (await requestGateway('free_tier.claim_nudge', params))?.claimed === true
} catch {
// Not claimed; the next status read tries again. No re-read here, or a failing call would loop.
return
@@ -221,10 +245,10 @@ async function claimFreeTierOffer(requestGateway: FreeTierRequester) {
}
if (claimed && $freeTierSignIn.get().status === 'closed') {
- set({ status: 'offer' })
+ set({ kind, status: 'offer' })
}
- // The claim settled the offer either way; re-read so the cached `nudge_due_in` drops.
+ // The claim settled the offer either way; re-read so the cached due time drops.
void refreshFreeTierStatus(requestGateway, isCurrent)
}
diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts
index dbbb1f22e7..927c98b200 100644
--- a/apps/desktop/src/types/hermes.ts
+++ b/apps/desktop/src/types/hermes.ts
@@ -201,6 +201,8 @@ export interface FreeTierStatus {
challenge?: FreeTierChallengePayload | null
/** Seconds until the one-time first-task sign-in offer is due (0 = now); null when none is owed. */
nudge_due_in?: number | null
+ /** The same for the connector offer: no trial, and apps connected on the free tier. */
+ connector_nudge_due_in?: number | null
}
export interface MemoryProviderOAuthStatus {
diff --git a/apps/shared/src/gateway-contract.generated.ts b/apps/shared/src/gateway-contract.generated.ts
index 19bbc924e3..57a5f6d677 100644
--- a/apps/shared/src/gateway-contract.generated.ts
+++ b/apps/shared/src/gateway-contract.generated.ts
@@ -678,6 +678,7 @@ export interface FreeTierStatusResult {
retry_after?: number | null
challenge?: FreeTierChallengePayload | null
nudge_due_in?: number | null
+ connector_nudge_due_in?: number | null
}
/** ``hermes_cli/anon_challenge.py::BrowserChallenge.as_payload``: the ``free_tier.challenge`` event, and ``free_tier.status``'s ``challenge`` field for a client that connected after it. */
export interface FreeTierChallengePayload {
@@ -709,6 +710,10 @@ export interface FreeTierProvisionResult {
export interface FreeTierAckNoticeResult {
acked: boolean
}
+export interface FreeTierClaimNudgeParams {
+ profile?: string | null
+ kind?: 'trial' | 'connector'
+}
export interface FreeTierClaimNudgeResult {
claimed: boolean
}
@@ -6169,7 +6174,7 @@ export interface RpcMethods {
/** Report a browser window outcome for the matching pending attempt; mint remains authoritative. */
'free_tier.challenge_result': { params: FreeTierChallengeResultParams; result: FreeTierChallengeResult }
/** Claim the due sign-in offer; true for exactly one caller each time an offer comes due. */
- 'free_tier.claim_nudge': { params: ProfileParams; result: FreeTierClaimNudgeResult }
+ 'free_tier.claim_nudge': { params: FreeTierClaimNudgeParams; result: FreeTierClaimNudgeResult }
/** Explicit retry of the free-tier identity mint when the boot bootstrap could not create it. */
'free_tier.provision': { params: ProfileParams; result: FreeTierProvisionResult }
/** Pure read of the focused profile's free-tier identity state (no network, no side effects). */
diff --git a/apps/shared/src/gateway-contract.openrpc.json b/apps/shared/src/gateway-contract.openrpc.json
index 96b343ba04..0e7e02c8a9 100644
--- a/apps/shared/src/gateway-contract.openrpc.json
+++ b/apps/shared/src/gateway-contract.openrpc.json
@@ -1075,7 +1075,7 @@
{
"name": "params",
"schema": {
- "$ref": "#/components/schemas/ProfileParams"
+ "$ref": "#/components/schemas/FreeTierClaimNudgeParams"
}
}
],
@@ -18636,6 +18636,34 @@
"title": "FreeTierChallengeResultParams",
"type": "object"
},
+ "FreeTierClaimNudgeParams": {
+ "additionalProperties": false,
+ "properties": {
+ "profile": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Profile"
+ },
+ "kind": {
+ "default": "trial",
+ "enum": [
+ "trial",
+ "connector"
+ ],
+ "title": "Kind",
+ "type": "string"
+ }
+ },
+ "title": "FreeTierClaimNudgeParams",
+ "type": "object"
+ },
"FreeTierClaimNudgeResult": {
"additionalProperties": false,
"properties": {
@@ -18815,6 +18843,18 @@
],
"default": null,
"title": "Nudge Due In"
+ },
+ "connector_nudge_due_in": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Connector Nudge Due In"
}
},
"required": [
diff --git a/hermes_cli/connectors_cmd.py b/hermes_cli/connectors_cmd.py
index 581c86bda2..1118edac18 100644
--- a/hermes_cli/connectors_cmd.py
+++ b/hermes_cli/connectors_cmd.py
@@ -9,7 +9,7 @@ from typing import Any, Callable, NoReturn
_POLL_SECONDS = 2.0
_DONE_STATES = frozenset({"connected", "skipped", "failed", "expired", "not_connected"})
-_SIGN_IN = "Sign in with `hermes login` to manage connectors."
+_SIGN_IN = "Run `hermes auth upgrade` to sign in."
class _CommandError(Exception):
@@ -60,7 +60,7 @@ def _match_named(app: str, rows: list[dict[str, Any]], name: str) -> dict[str, A
def _list(args) -> int:
from tools.connectors.managed import managed_client
- rows = managed_client().list_connectors()
+ rows = managed_client(creates_guest=True).list_connectors()
if not rows:
print("No connectors are available.")
return 0
@@ -237,7 +237,7 @@ def cmd_connectors(args) -> int:
print(f"Unknown connectors action: {args.connectors_action}", file=sys.stderr)
return 2
if not connectors_available():
- print("Connectors are not available. Sign in with `hermes login` first.", file=sys.stderr)
+ print(f"Connectors are not available. {_SIGN_IN}", file=sys.stderr)
return 1
try:
return handler(args)
diff --git a/hermes_cli/free_tier_offer.py b/hermes_cli/free_tier_offer.py
index 89ca624741..bc0facfa8d 100644
--- a/hermes_cli/free_tier_offer.py
+++ b/hermes_cli/free_tier_offer.py
@@ -1,53 +1,72 @@
-"""When the free tier's "sign in for more" offer is due: after finished tasks, backing off.
+"""When a free-tier "sign in" offer is due: after finished tasks, backing off.
+
+Two offers share one schedule. ``trial`` is the welcome trial's "sign in for more"; a finished task
+is ``tui_gateway.free_tier_task_done``'s call. ``connector`` is for a guest that has connected an app
+on its own model key; each app that connects counts as a finished task (``note_app_connected``).
The first offer comes ``OFFER_DELAY_S`` after the first finished task. Each later offer needs another
finished task after the previous offer, comes ``OFFER_DELAY_S`` after that task, and never sooner than
``REOFFER_AFTER_S[n]`` after the previous offer, so a user who keeps saying "Not now" hears it less
-often. "Finished task" is ``tui_gateway.free_tier_task_done``'s call. The record is install-wide, at
-``/free_tier/sign_in_offer.json``. Signing in ends the free tier, and with it every offer.
+often. Each record is install-wide, under ``/free_tier/``. Signing in ends the guest,
+and with it every offer.
"""
from __future__ import annotations
import json
+import logging
import math
import threading
import time
from contextlib import contextmanager
-from typing import Any, Dict, Iterator, Optional
+from typing import Any, Iterator, Optional
+
+logger = logging.getLogger(__name__)
OFFER_DELAY_S = 180
REOFFER_AFTER_S = (60 * 60, 4 * 60 * 60, 24 * 60 * 60) # after the 1st, 2nd, then every later offer
+TRIAL = "trial"
+CONNECTOR = "connector"
+CONNECTED_APP_FLAG = "has_connected_app"
+
+_RECORDS = {TRIAL: "sign_in_offer.json", CONNECTOR: "connector_offer.json"}
+
_LOCK = threading.local()
_clock = time.time # tests swap this for a fake clock
-def _path():
+def _path(kind: str):
from hermes_constants import get_default_hermes_root
- return get_default_hermes_root() / "free_tier" / "sign_in_offer.json"
+ return get_default_hermes_root() / "free_tier" / _RECORDS[kind]
-def _read() -> dict[str, Any]:
+def _read(kind: str) -> dict[str, Any]:
try:
- data = json.loads(_path().read_text(encoding="utf-8-sig"))
+ data = json.loads(_path(kind).read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) and isinstance(data.get("first_task_at"), (int, float)) else {}
@contextmanager
-def _transaction() -> Iterator[tuple[dict[str, Any], Any]]:
+def _transaction(kind: str) -> Iterator[tuple[dict[str, Any], Any]]:
from hermes_cli.auth import _file_lock
from utils import atomic_write_text
- path = _path()
+ path = _path(kind)
with _file_lock(path.with_suffix(".lock"), _LOCK, 5.0, f"Timed out waiting for the sign-in offer record ({path})"):
- yield _read(), lambda data: atomic_write_text(path, json.dumps(data))
+ yield _read(kind), lambda data: atomic_write_text(path, json.dumps(data))
-def _on_free_tier() -> bool:
+def _eligible(kind: str) -> bool:
+ """``trial``: the welcome trial carries inference. ``connector``: a guest that connected an app,
+ guests are still allowed (``nous.guest``), and no trial makes its own offer."""
from hermes_cli import anon_auth
- return anon_auth.free_inference_active()
+ if kind == TRIAL:
+ return anon_auth.free_inference_active()
+ state = anon_auth.current_nous_state()
+ return (anon_auth.is_guest_state(state) and state.get(CONNECTED_APP_FLAG) is True and anon_auth.guest_allowed()
+ and not anon_auth.free_inference_active(state))
def _due_at(state: dict[str, Any]) -> Optional[float]:
@@ -63,30 +82,55 @@ def _due_at(state: dict[str, Any]) -> Optional[float]:
return max(last_task + OFFER_DELAY_S, last_offer + REOFFER_AFTER_S[min(offers, len(REOFFER_AFTER_S)) - 1])
-def _due_in(state: dict[str, Any]) -> Optional[int]:
+def _due_in(kind: str, state: dict[str, Any]) -> Optional[int]:
due_at = _due_at(state)
- if due_at is None or not _on_free_tier():
+ if due_at is None or not _eligible(kind):
return None
return max(0, math.ceil(due_at - _clock()))
-def record_task_done() -> None:
- """A task finished on the free tier: the first one starts the offer clock, later ones re-arm it."""
- with _transaction() as (state, write):
+def record_task_done(kind: str = TRIAL) -> None:
+ """A task finished: the first one starts the offer clock, later ones re-arm it."""
+ with _transaction(kind) as (state, write):
now = _clock()
write({**state, "first_task_at": state.get("first_task_at", now), "last_task_at": now})
-def offer_due_in() -> Optional[int]:
- """Seconds until the offer is due (0 = now); None when none is pending or the user is not on the
- free tier. A pure read."""
- return _due_in(_read())
+def note_app_connected() -> None:
+ """An app finished connecting. On a guest identity, count a finished task for the connector offer,
+ then flag the guest (so the flag dies with it). The flag ends list-read sightings, so it is set only
+ once the task is recorded. Never fails the connection."""
+ from hermes_cli import anon_auth
+ try:
+ if anon_auth.is_guest_state(anon_auth.current_nous_state()):
+ record_task_done(CONNECTOR)
+ anon_auth.set_guest_flag(CONNECTED_APP_FLAG)
+ except Exception:
+ logger.debug("connector sign-in offer record failed", exc_info=True)
-def claim_offer() -> bool:
+def note_connected_apps_seen(rows: list[Any]) -> None:
+ """A connector list read (agent status, the Connectors page). The first one that shows a guest an
+ app connected counts as the connect: a connect by link (messaging, one-shot runs) never passes the
+ operation watcher, and an older Hermes connected apps without setting the flag."""
+ from hermes_cli import anon_auth
+ if not any(isinstance(row, dict) and row.get("connected") for row in rows):
+ return
+ state = anon_auth.current_nous_state()
+ if anon_auth.is_guest_state(state) and not state.get(CONNECTED_APP_FLAG):
+ note_app_connected()
+
+
+def offer_due_in(kind: str = TRIAL) -> Optional[int]:
+ """Seconds until the offer is due (0 = now); None when none is pending or the user is not
+ eligible for it. A pure read."""
+ return _due_in(kind, _read(kind))
+
+
+def claim_offer(kind: str = TRIAL) -> bool:
"""Compare-and-set under the lock: True for exactly one caller each time an offer comes due."""
- with _transaction() as (state, write):
- if _due_in(state) != 0:
+ with _transaction(kind) as (state, write):
+ if _due_in(kind, state) != 0:
return False
write({**state, "offers": int(state.get("offers") or 0) + 1, "last_offered_at": _clock()})
return True
diff --git a/locales/_keys.desktop.json b/locales/_keys.desktop.json
index 0e82256f4f..ae0fe91d33 100644
--- a/locales/_keys.desktop.json
+++ b/locales/_keys.desktop.json
@@ -1089,6 +1089,8 @@
"composer.wakeWordOff",
"composer.wakeWordPausedVoice",
"composer.wakingProfile",
+ "connectorOffer.body",
+ "connectorOffer.heading",
"connectors.authorizedToolsUnavailable",
"connectors.cancel",
"connectors.checking",
diff --git a/tests/hermes_cli/test_free_tier_offer.py b/tests/hermes_cli/test_free_tier_offer.py
index 4cb9439e69..584a55ca05 100644
--- a/tests/hermes_cli/test_free_tier_offer.py
+++ b/tests/hermes_cli/test_free_tier_offer.py
@@ -111,3 +111,23 @@ def test_never_offered_off_the_free_tier(clock, monkeypatch):
monkeypatch.setattr(anon_auth, "free_inference_active", lambda state=None: False)
assert free_tier_offer.offer_due_in() is None
assert free_tier_offer.claim_offer() is False
+
+
+@pytest.mark.parametrize("guest,connected,trial,allowed,offered", [
+ (True, True, False, True, True), # a guest on its own key that connected an app
+ (True, False, False, True, False), # a guest that connected nothing
+ (True, True, True, True, False), # the trial makes its own offer
+ (False, True, False, True, False), # signed in: no guest, no offer
+ (True, True, False, False, False), # nous.guest: false, the guest left on disk
+], ids=["connector-guest", "nothing-connected", "on-trial", "signed-in", "guests-off"])
+def test_connector_offer_is_only_for_a_guest_that_connected_an_app_without_the_trial(
+ clock, monkeypatch, guest, connected, trial, allowed, offered):
+ state = {"auth_method": anon_auth.ANON_AUTH_METHOD if guest else "device_code"}
+ if connected:
+ state[free_tier_offer.CONNECTED_APP_FLAG] = True
+ monkeypatch.setattr(anon_auth, "current_nous_state", lambda: state)
+ monkeypatch.setattr(anon_auth, "free_inference_active", lambda state=None: trial)
+ monkeypatch.setattr(anon_auth, "guest_allowed", lambda: allowed)
+ free_tier_offer.record_task_done(free_tier_offer.CONNECTOR)
+ assert free_tier_offer.offer_due_in(free_tier_offer.CONNECTOR) == (DELAY if offered else None)
+ assert free_tier_offer.offer_due_in() is None # the trial's record is its own
diff --git a/tests/tools/test_connector_wire_identity.py b/tests/tools/test_connector_wire_identity.py
index 65251921a2..93cfe0b6a1 100644
--- a/tests/tools/test_connector_wire_identity.py
+++ b/tests/tools/test_connector_wire_identity.py
@@ -145,7 +145,8 @@ def test_each_connect_path_names_its_initiator(monkeypatch):
for initiator in (managed.AGENT_TOOL, managed.DESKTOP_PAGE):
operation = ConnectionOperation([Target("gmail", "connector", "connect")])
- client = managed.managed_client() if initiator == managed.AGENT_TOOL else managed.managed_client(initiator=initiator)
+ client = (managed.managed_client(creates_guest=True) if initiator == managed.AGENT_TOOL
+ else managed.managed_client(creates_guest=True, initiator=initiator))
managed.mint(client, operation, ["gmail"], reinitiate=False, actor=Actor.backend_watcher)
operation.transition("gmail", TargetState.failed, Actor.backend_watcher)
diff --git a/tests/tui_gateway/test_connectors_page_identity.py b/tests/tui_gateway/test_connectors_page_identity.py
new file mode 100644
index 0000000000..d3e6101170
--- /dev/null
+++ b/tests/tui_gateway/test_connectors_page_identity.py
@@ -0,0 +1,188 @@
+"""The desktop Connectors page, the CLI and the agent tool, and the guest identity behind hosted connectors.
+
+Contracts, for a user with no Nous identity (the fake account service in
+``tests.hermes_cli.anon_portal``, Hermes' real RPC handlers and CLI):
+- opening the page (list, catalog, accounts) creates no identity; the catalog comes from the Portal's
+ public route, asked once and without a bearer, and rules are refused rather than invented
+- ``hermes connectors list`` and the agent's ``manage_connections`` status each create exactly one
+ identity and make the gateway list call, as before
+- the first connect creates one identity, arms the connector sign-in offer, and the page then reads
+ the signed-in routes; a connect by link that a later status read sees connected arms it too, and
+ so does a page list that shows a guest's app connected on an older Hermes
+"""
+
+from __future__ import annotations
+
+import json
+from argparse import Namespace
+
+import pytest
+
+import tui_gateway.server as srv
+from hermes_cli import free_tier_offer
+from tests.hermes_cli.anon_portal import install_portal
+from tests.waiting import wait_until
+from tools.connectors import live
+
+ACCOUNT = {"owner": {"type": "account"}}
+CATALOG = {"connectors": [{"slug": "notion", "name": "Notion", "description": "Docs", "category": "Productivity",
+ "logoUrl": None, "state": "live"}]}
+
+
+class _Response:
+ def __init__(self, body):
+ self.status_code, self._body, self.headers = 200, body, {}
+
+ def json(self):
+ return self._body
+
+
+@pytest.fixture
+def portal(monkeypatch, tmp_path):
+ fake = install_portal(monkeypatch, tmp_path)
+ live.reset_for_tests()
+ yield fake
+ live.reset_for_tests()
+
+
+@pytest.fixture
+def connector_portal(monkeypatch):
+ """The Portal's connector routes: every request as ``(path, sent a bearer)``."""
+ requests = []
+
+ class Transport:
+ def request(self, method, url, *, headers=None, json=None, timeout=None):
+ path = "/" + url.split("://", 1)[1].split("/", 1)[1]
+ requests.append((path, "Authorization" in (headers or {})))
+ return _Response({"accounts": []} if path.endswith("/accounts") else CATALOG)
+
+ monkeypatch.setattr("tools.connectors.portal.client._default_transport", Transport)
+ return requests
+
+
+class _Lists(list):
+ connected = False
+
+
+@pytest.fixture
+def gateway(monkeypatch):
+ """The connector gateway: every list call, and an instant connect. ``connected`` is what it lists."""
+ lists = _Lists()
+
+ class Gateway:
+ def __init__(self, **_):
+ pass
+
+ def list_connectors(self, *, timeout=None):
+ lists.append(1)
+ return [{"connector": "notion", "enabled": True, "connected": lists.connected}]
+
+ def connections(self, names, **_):
+ return {"results": [{"connector": name, "status": "active", "connection_id": "c1"} for name in names]}
+
+ monkeypatch.setattr("tools.connectors.gateway.client.ConnectorClient", Gateway)
+ return lists
+
+
+def _call(method: str, params: dict | None = None) -> dict:
+ return srv.handle_request({"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}})
+
+
+def _result(method: str, params: dict | None = None) -> dict:
+ response = _call(method, params)
+ assert "result" in response, response
+ return response["result"]
+
+
+def test_opening_the_page_creates_no_identity_and_reads_the_public_catalog(portal, connector_portal, gateway):
+ assert _result("connectors.list", ACCOUNT) == {"available": True, "connectors": []}
+ assert [row["slug"] for row in _result("connectors.catalog")["connectors"]] == ["notion"]
+ assert _result("connectors.accounts") == {"accounts": []}
+ assert _call("connectors.policy.get")["error"]["code"] == 4032
+
+ assert connector_portal == [("/api/v1/connectors/catalog/public", False)]
+ assert portal.creates() == 0 and gateway == []
+
+
+@pytest.mark.parametrize("entry", ["cli", "agent"])
+def test_explicit_lists_still_create_one_identity_and_list_from_the_gateway(portal, gateway, entry):
+ from hermes_cli.connectors_cmd import cmd_connectors
+ from tools.connectors.gateway.config import connectors_available
+ from tools.connectors.tool import manage_connections
+
+ if entry == "cli":
+ assert cmd_connectors(Namespace(connectors_action="list")) == 0
+ else:
+ result = json.loads(manage_connections({"action": "status"}, connectors_available=connectors_available))
+ assert result["connectors"][0]["connector"] == "notion"
+
+ assert portal.creates() == 1 and gateway == [1]
+
+
+def test_first_connect_creates_one_identity_then_the_page_reads_signed_in(
+ portal, connector_portal, gateway, monkeypatch):
+ monkeypatch.setattr(free_tier_offer, "_clock", lambda: 1_000_000.0)
+
+ _result("connectors.connect", {**ACCOUNT, "connectors": ["notion"]})
+
+ assert wait_until(lambda: "connector_nudge_due_in" in _result("free_tier.status"), timeout=5)
+ status = _result("free_tier.status")
+ assert status["connector_nudge_due_in"] == free_tier_offer.OFFER_DELAY_S
+ assert "nudge_due_in" not in status and status["has_guest"] and not status["available"]
+
+ assert _result("connectors.list", ACCOUNT)["connectors"][0]["connector"] == "notion"
+ _result("connectors.catalog")
+ assert connector_portal == [("/api/v1/connectors/catalog", True)]
+ assert portal.creates() == 1
+
+
+def test_a_connect_by_link_seen_by_a_status_read_arms_the_connector_offer(portal, gateway, monkeypatch):
+ """Messaging and one-shot runs connect by link and check with status; no operation watcher runs."""
+ from tools.connectors.gateway.config import connectors_available
+ from tools.connectors.tool import manage_connections
+
+ monkeypatch.setattr(free_tier_offer, "_clock", lambda: 1_000_000.0)
+ gateway.connected = True
+
+ for _ in range(2):
+ manage_connections({"action": "status"}, connectors_available=connectors_available)
+
+ assert free_tier_offer.offer_due_in(free_tier_offer.CONNECTOR) == free_tier_offer.OFFER_DELAY_S
+ assert portal.creates() == 1
+
+
+def test_the_page_list_arms_the_offer_for_apps_a_guest_connected_on_an_older_hermes(portal, gateway, monkeypatch):
+ """The guest exists without the connected-app flag (an older Hermes connected its apps); the
+ first page read that lists one connected must still arm the connector offer."""
+ from tools.connectors import ensure_guest_identity
+
+ monkeypatch.setattr(free_tier_offer, "_clock", lambda: 1_000_000.0)
+ assert ensure_guest_identity() is None
+ gateway.connected = True
+
+ assert _result("connectors.list", ACCOUNT)["connectors"][0]["connected"] is True
+
+ assert free_tier_offer.offer_due_in(free_tier_offer.CONNECTOR) == free_tier_offer.OFFER_DELAY_S
+ assert portal.creates() == 1
+
+
+def test_a_failed_offer_write_is_retried_on_the_next_sighting(portal, gateway, monkeypatch):
+ """The flag that ends the sightings is written only once the offer's schedule is, so a failed
+ schedule write leaves the next list read to try again."""
+ from tools.connectors import ensure_guest_identity
+
+ monkeypatch.setattr(free_tier_offer, "_clock", lambda: 1_000_000.0)
+ assert ensure_guest_identity() is None
+ gateway.connected = True
+ real_record = free_tier_offer.record_task_done
+
+ def fail_once(kind=free_tier_offer.TRIAL):
+ monkeypatch.setattr(free_tier_offer, "record_task_done", real_record)
+ raise TimeoutError("offer record locked")
+
+ monkeypatch.setattr(free_tier_offer, "record_task_done", fail_once)
+ _result("connectors.list", ACCOUNT)
+ assert free_tier_offer.offer_due_in(free_tier_offer.CONNECTOR) is None
+
+ _result("connectors.list", ACCOUNT)
+ assert free_tier_offer.offer_due_in(free_tier_offer.CONNECTOR) == free_tier_offer.OFFER_DELAY_S
diff --git a/tests/tui_gateway/test_legacy_fallback_ticket_profile_verbs.py b/tests/tui_gateway/test_legacy_fallback_ticket_profile_verbs.py
index 50b1b37ffe..5c07bba8e7 100644
--- a/tests/tui_gateway/test_legacy_fallback_ticket_profile_verbs.py
+++ b/tests/tui_gateway/test_legacy_fallback_ticket_profile_verbs.py
@@ -37,6 +37,7 @@ def test_secondary_ticket_runs_connectors_and_billing_in_its_own_profile(homes,
import hermes_cli.nous_billing
import tools.connectors
import tools.connectors.managed
+ import tools.managed_tool_gateway
from hermes_constants import get_hermes_home
server, launch, bee = homes
seen = []
@@ -45,8 +46,9 @@ def test_secondary_ticket_runs_connectors_and_billing_in_its_own_profile(homes,
seen.append((label, Path(str(get_hermes_home())).resolve()))
monkeypatch.setattr(tools.connectors, 'connectors_available', lambda *a, **k: True)
+ monkeypatch.setattr(tools.managed_tool_gateway, 'read_nous_access_token', lambda: 'token')
monkeypatch.setattr(tools.connectors.managed, 'managed_client',
- lambda: types.SimpleNamespace(list_connectors=lambda: rec('list') or []))
+ lambda **_: types.SimpleNamespace(list_connectors=lambda: rec('list') or []))
monkeypatch.setattr(hermes_cli.nous_billing, 'post_charge', lambda **k: rec('charge') or {'chargeId': 'c'})
for method, params in (('connectors.list', {'owner': {'type': 'account'}}), ('billing.charge', {'amount_usd': 5})):
_call(server, bee, method, params)
diff --git a/tools/connectors/account.py b/tools/connectors/account.py
index 5374b8f3b4..922af03550 100644
--- a/tools/connectors/account.py
+++ b/tools/connectors/account.py
@@ -77,7 +77,7 @@ def _run(start: AccountOperationStart, action: str, initiator: str | None) -> No
try:
drive_operation(
start.operation,
- managed.managed_kind(managed.managed_client(initiator=initiator), action, force=False),
+ managed.managed_kind(managed.managed_client(creates_guest=True, initiator=initiator), action, force=False),
connection_callback=lambda _payload: start.done.set(),
tick_seconds=managed.WATCH_TICK_SECONDS,
with_urls_in_result=False,
diff --git a/tools/connectors/managed.py b/tools/connectors/managed.py
index 10b6f97bbf..21ab8925a1 100644
--- a/tools/connectors/managed.py
+++ b/tools/connectors/managed.py
@@ -51,20 +51,40 @@ DESKTOP_PAGE = "desktop_page"
CARD_RETRY = "card_retry"
-def managed_client(*, initiator: Optional[str] = AGENT_TOOL, call: Optional[Any] = None):
- """The real gateway client for a user action. The first one creates the guest identity when the
- user has none: every hosted action is a request for an app. ``initiator`` names who asked, so
- every link the client mints is attributed."""
+def managed_client(*, creates_guest: bool, initiator: Optional[str] = AGENT_TOOL, call: Optional[Any] = None):
+ """The real gateway client for a user action. ``creates_guest`` is the caller's declaration that
+ it may create the guest identity when the user has none (a connect, an explicit list); a page
+ read passes False and holds a token already. ``initiator`` names who asked, so every link the
+ client mints is attributed."""
from tools.connectors.gateway.client import ConnectorClient
from tools.connectors.gateway.errors import ToolGatewayError
- if setup_error := ensure_guest_identity():
+ if creates_guest and (setup_error := ensure_guest_identity()):
raise ToolGatewayError(setup_error, code=GUEST_SETUP_FAILED, retryable=True)
return ConnectorClient(initiator=initiator, call=call)
def managed_kind(client: Any, action: str, force: bool) -> Kind:
- return Kind(prepare=_prepare(client, action, force), observe=lambda operation: _observe(client, operation), note=NOTE)
+ noted: set[str] = set()
+
+ def noting(step: Callable[[ConnectionOperation], None]) -> Callable[[ConnectionOperation], None]:
+ def run(operation: ConnectionOperation) -> None:
+ step(operation)
+ _note_connected(operation, noted)
+ return run
+
+ return Kind(prepare=noting(_prepare(client, action, force)),
+ observe=noting(lambda operation: _observe(client, operation)), note=NOTE)
+
+
+def _note_connected(operation: ConnectionOperation, noted: set[str]) -> None:
+ """Each app that connects counts once toward the connector sign-in offer."""
+ fresh = {t.name for t in operation.targets if t.state == TargetState.connected} - noted
+ if fresh:
+ from hermes_cli.free_tier_offer import note_app_connected
+
+ noted.update(fresh)
+ note_app_connected()
def _status_by_slug(client: Any) -> dict[str, dict[str, Any]]:
@@ -336,6 +356,9 @@ def _no_card_result(client: Any, action: str, targets: list[HostedTarget], force
def _status_result(client: Any, connectors: list[str]) -> str:
items = client.list_connectors()
+ from hermes_cli.free_tier_offer import note_connected_apps_seen
+
+ note_connected_apps_seen(items)
known = {str(i.get("connector", "")).lower() for i in items}
unknown = {name: _unknown_slug(name, known) for name in connectors if name not in known}
if connectors:
@@ -427,7 +450,7 @@ def run_managed_action(
if action == "rename":
return _rename_result(targets[0])
call = CallRef(turn_id=turn_id, tool_call_id=tool_call_id)
- client = client_factory() if client_factory else managed_client(call=call)
+ client = client_factory() if client_factory else managed_client(creates_guest=True, call=call)
if action == "display":
return _display_result(client, [t.name for t in targets], session_id=session_id, tool_call_id=tool_call_id,
connection_callback=connection_callback if card_surface else None, call=call)
diff --git a/tools/connectors/portal/client.py b/tools/connectors/portal/client.py
index 40c159ff77..3af5506953 100644
--- a/tools/connectors/portal/client.py
+++ b/tools/connectors/portal/client.py
@@ -124,6 +124,12 @@ class PortalConnectorClient:
response, status, route = self._request("GET", "/api/v1/connectors/catalog")
return self._parse(ConnectorCatalogResponse, response, status, route, PortalConnectorUnavailable, "portal catalog unavailable")
+ def public_catalog(self) -> ConnectorCatalogResponse:
+ """The catalog a new guest would get, for a caller with no token yet. Sent without a bearer so
+ every caller gets the one cached answer."""
+ response, status, route = self._request("GET", "/api/v1/connectors/catalog/public", public=True)
+ return self._parse(ConnectorCatalogResponse, response, status, route, PortalConnectorUnavailable, "portal catalog unavailable")
+
def list_accounts(self) -> list[dict[str, Any]]:
response, status, route = self._request("GET", "/api/v1/connectors/accounts")
inventory = self._parse(ConnectorAccountsResponse, response, status, route, PortalConnectorUnavailable, "portal accounts unavailable")
@@ -156,11 +162,15 @@ class PortalConnectorClient:
body: dict[str, Any] | None = None,
*,
headers: dict[str, str] | None = None,
+ public: bool = False,
) -> tuple[Any, int, str]:
"""The answer, its status and route key. A failed status is logged and raised here; a 2xx is
- logged by ``_parse`` once its body is checked."""
+ logged by ``_parse`` once its body is checked. ``public`` routes get no Authorization."""
url = f"{self.origin()}{path}"
- headers = self._authorized_headers(url, headers)
+ if public:
+ headers = {"Accept": "application/json", **(headers or {}), **connector_headers()}
+ else:
+ headers = self._authorized_headers(url, headers)
try:
response = self._transport.request(method, url, headers=headers, json=body, timeout=DEFAULT_TIMEOUT_SECONDS)
except Exception as exc:
@@ -199,7 +209,7 @@ def _route_key(path: str) -> str:
return ".".join(["portal", *named]) or "portal"
-_PORTAL_ROUTE_WORDS = frozenset({"connectors", "tools", "catalog", "accounts", "policy"})
+_PORTAL_ROUTE_WORDS = frozenset({"connectors", "tools", "catalog", "public", "accounts", "policy"})
def _safe_json(response: Any) -> Any:
diff --git a/tools/connectors/run.py b/tools/connectors/run.py
index c0a8a49a63..3f682ae5d4 100644
--- a/tools/connectors/run.py
+++ b/tools/connectors/run.py
@@ -61,7 +61,7 @@ def reissue(operation: ConnectionOperation, names: Sequence[str], *, initiator:
if targets[0].kind == "connector":
from tools.connectors.managed import CARD_RETRY, managed_client, mint
- client = managed_client(initiator=initiator or CARD_RETRY, call=operation.call)
+ client = managed_client(creates_guest=True, initiator=initiator or CARD_RETRY, call=operation.call)
mint(client, operation, stale, reinitiate=True, actor=Actor.user)
return None
from tools.connectors import catalog
diff --git a/tui_gateway/contracts/config_free_tier_control.py b/tui_gateway/contracts/config_free_tier_control.py
index ee0cf94c2d..a7346e3f0a 100644
--- a/tui_gateway/contracts/config_free_tier_control.py
+++ b/tui_gateway/contracts/config_free_tier_control.py
@@ -207,6 +207,8 @@ class FreeTierStatusResult(Result):
# Seconds until the sign-in offer after a finished task is due (0 = due now); absent when none is
# pending (no finished task since the last offer, or not on the free tier).
nudge_due_in: int | None = None
+ # The same for the connector offer: a guest with no trial that has connected an app.
+ connector_nudge_due_in: int | None = None
method("free_tier.status", params=ProfileParams, result=FreeTierStatusResult,
@@ -248,11 +250,15 @@ method("free_tier.ack_notice", params=ProfileParams, result=FreeTierAckNoticeRes
doc="Mark the one-time availability notice as shown on the free-tier identity.")
+class FreeTierClaimNudgeParams(ProfileParams):
+ kind: Literal["trial", "connector"] = "trial"
+
+
class FreeTierClaimNudgeResult(Result):
claimed: bool
-method("free_tier.claim_nudge", params=ProfileParams, result=FreeTierClaimNudgeResult,
+method("free_tier.claim_nudge", params=FreeTierClaimNudgeParams, result=FreeTierClaimNudgeResult,
doc="Claim the due sign-in offer; true for exactly one caller each time an offer comes due.")
diff --git a/tui_gateway/methods_connectors.py b/tui_gateway/methods_connectors.py
index 18ccbda5bf..47f9735478 100644
--- a/tui_gateway/methods_connectors.py
+++ b/tui_gateway/methods_connectors.py
@@ -82,8 +82,8 @@ def _account_gate_closed(rid):
def _account_identity_error(rid):
- """Opening the Connectors page or connecting from it is the user asking for apps: create the
- guest identity here when the user has none. Operation polls never call this."""
+ """Connecting from the Connectors page is the user asking for apps: create the guest identity
+ here when the user has none. Page reads and operation polls never call this."""
from tools.connectors import ensure_guest_identity
from tui_gateway.contracts.connectors import ConnectorErrorReason
@@ -190,10 +190,18 @@ def _connector_rows(rows):
])
-def _account_connector_list(rid):
- from tools.connectors.managed import managed_client
+# Whether each account-owned action of the Connectors page may create the guest identity.
+_ACCOUNT_CREATES_GUEST = {"status": False, "connect": True}
- return _ok(rid, {"available": True, "connectors": _connector_rows(managed_client().list_connectors())})
+
+def _account_connector_list(rid):
+ from hermes_cli.free_tier_offer import note_connected_apps_seen
+ from tools.connectors.managed import managed_client
+ from tools.managed_tool_gateway import read_nous_access_token
+
+ rows = managed_client(creates_guest=False).list_connectors() if read_nous_access_token() else []
+ note_connected_apps_seen(rows)
+ return _ok(rid, {"available": True, "connectors": _connector_rows(rows)})
def _account_connector_connect(rid, request):
@@ -232,7 +240,7 @@ def _connector_rpc(rid, params, action):
with _account_scope(request):
if closed := _account_gate_closed(rid):
return _ok(rid, {"available": False, "connectors": []}) if action == "status" else closed
- if setup_error := _account_identity_error(rid):
+ if _ACCOUNT_CREATES_GUEST[action] and (setup_error := _account_identity_error(rid)):
return setup_error
return _account_connector_list(rid) if action == "status" else _account_connector_connect(rid, request)
diff --git a/tui_gateway/methods_connectors_account.py b/tui_gateway/methods_connectors_account.py
index 3a7d86a75c..4194c20052 100644
--- a/tui_gateway/methods_connectors_account.py
+++ b/tui_gateway/methods_connectors_account.py
@@ -14,20 +14,24 @@ method = _registry.method
_profile_scoped = _registry.profile_scoped
-def _account_method(params_model=None, *, invalid="", invalid_reason=ConnectorErrorReason.invalid_params,
+def _account_method(params_model=None, *, creates_guest, invalid="", invalid_reason=ConnectorErrorReason.invalid_params,
unavailable, unavailable_message):
+ """``creates_guest``: whether this entry point may create the guest identity when the user has
+ none. The page's reads never do; they answer from what a caller without a token can see."""
def decorate(fn):
def handler(rid, params):
from pydantic import ValidationError
from tools.connectors.gateway.errors import GatewayAuthError
with _desktop_page_vars():
- if gate_error := _account_gate_closed(rid) or _account_identity_error(rid):
+ if gate_error := _account_gate_closed(rid):
return gate_error
try:
request = params if params_model is None else params_model.model_validate(params)
except ValidationError:
return _connector_rpc_error(rid, 4000, invalid_reason, invalid)
+ if creates_guest and (identity_error := _account_identity_error(rid)):
+ return identity_error
try:
return fn(rid, request)
except GatewayAuthError as exc:
@@ -48,6 +52,7 @@ def _account_method(params_model=None, *, invalid="", invalid_reason=ConnectorEr
@_profile_scoped
@_account_method(
ConnectorToolsParams,
+ creates_guest=True,
invalid="slug and refresh are required parameters",
unavailable=ConnectorErrorReason.tools_unavailable,
unavailable_message="Connector tools are unavailable.",
@@ -71,16 +76,17 @@ def _(rid, request):
@method("connectors.catalog")
@_profile_scoped
@_account_method(
+ creates_guest=False,
unavailable=ConnectorErrorReason.catalog_unavailable,
unavailable_message="Connector catalog is unavailable.",
)
def _(rid, _params):
from tools.connectors.portal.client import PortalConnectorClient
+ from tools.managed_tool_gateway import read_nous_access_token
from tui_gateway.contracts.connectors import ConnectorsCatalogResult
client = PortalConnectorClient()
- client.require_authentication()
- catalog = client.catalog()
+ catalog = client.catalog() if read_nous_access_token() else client.public_catalog()
return _ok(rid, ConnectorsCatalogResult.model_validate(catalog, from_attributes=True).model_dump(mode="json"))
@@ -88,15 +94,17 @@ def _(rid, _params):
@_profile_scoped
@_account_method(
ConnectorAccountsParams,
+ creates_guest=False,
invalid="connector must be a slug.",
unavailable=ConnectorErrorReason.accounts_unavailable,
unavailable_message="Connector accounts are unavailable.",
)
def _(rid, request):
from tools.connectors.portal.client import PortalConnectorClient
+ from tools.managed_tool_gateway import read_nous_access_token
from tui_gateway.contracts.connectors import ConnectorAccountRow, ConnectorAccountsResult
- accounts = PortalConnectorClient().list_accounts()
+ accounts = PortalConnectorClient().list_accounts() if read_nous_access_token() else []
rows = [account for account in accounts if request.connector is None or account["connector"] == request.connector]
result = ConnectorAccountsResult(accounts=[_account_row(account) for account in rows])
return _ok(rid, result.model_dump(mode="json"))
@@ -123,6 +131,7 @@ def _account_row(account):
@_profile_scoped
@_account_method(
ConnectorAccountsRenameParams,
+ creates_guest=True,
invalid="connection_id and a valid alias are required.",
unavailable=ConnectorErrorReason.accounts_unavailable,
unavailable_message="Connector accounts are unavailable.",
@@ -160,6 +169,7 @@ def _(rid, request):
@_profile_scoped
@_account_method(
ConnectorAccountsRemoveParams,
+ creates_guest=True,
invalid="connection_id is required.",
unavailable=ConnectorErrorReason.accounts_unavailable,
unavailable_message="Connector accounts are unavailable.",
@@ -192,6 +202,7 @@ def _(rid, request):
@method("connectors.policy.get")
@_profile_scoped
@_account_method(
+ creates_guest=False,
unavailable=ConnectorErrorReason.policy_unavailable,
unavailable_message="Connector policy is unavailable.",
)
@@ -222,6 +233,7 @@ def _(rid, _params):
@_profile_scoped
@_account_method(
ConnectorPolicySetParams,
+ creates_guest=True,
invalid="Connector parameters are invalid.",
unavailable=ConnectorErrorReason.policy_unavailable,
unavailable_message="Connector policy is unavailable.",
diff --git a/tui_gateway/methods_free_tier.py b/tui_gateway/methods_free_tier.py
index 2047ce003a..66c0884f19 100644
--- a/tui_gateway/methods_free_tier.py
+++ b/tui_gateway/methods_free_tier.py
@@ -26,7 +26,8 @@ def _(rid, params: dict) -> dict:
inference actually runs on it is a ROUTE question answered by ``setup.runtime_check.free_tier``.
``notice_pending`` is true until ``free_tier.ack_notice`` ran for this identity.
``nudge_due_in``: seconds until the sign-in offer after a finished task is due (install-wide,
- ``hermes_cli.free_tier_offer``); absent when none is pending.
+ ``hermes_cli.free_tier_offer``); absent when none is pending. ``connector_nudge_due_in``: the
+ same for a guest without the trial that has connected an app.
A pure read: an identity is never created as a side effect of polling this (NS-845 Q1.2)."""
try:
@@ -39,8 +40,9 @@ def _(rid, params: dict) -> dict:
"notice_pending": bool(available and anon_auth.guest_notice_pending()),
"model": anon_auth.GUEST_MODEL, "label": anon_auth.FREE_TIER_LABEL}
from hermes_cli import free_tier_offer
- if (nudge_due_in := free_tier_offer.offer_due_in()) is not None:
- payload["nudge_due_in"] = nudge_due_in
+ for key, kind in (("nudge_due_in", free_tier_offer.TRIAL), ("connector_nudge_due_in", free_tier_offer.CONNECTOR)):
+ if (due_in := free_tier_offer.offer_due_in(kind)) is not None:
+ payload[key] = due_in
if enabled and not has_guest:
# Why there is no identity, when the last claim failed:
# ``{error, error_code, retryable, retry_after}`` (the mint memo's verdict).
@@ -103,11 +105,12 @@ def _(rid, params: dict) -> dict:
@method("free_tier.claim_nudge")
@_profile_scoped
def _(rid, params: dict) -> dict:
- """Claim the due sign-in offer. Claim before showing, so two windows never both show it:
- ``claimed`` is true for exactly one caller each time an offer comes due."""
+ """Claim the due sign-in offer of ``kind`` (``trial`` or ``connector``). Claim before showing,
+ so two windows never both show it: ``claimed`` is true for exactly one caller each time an offer
+ comes due."""
try:
from hermes_cli import free_tier_offer
- return _ok(rid, {"claimed": bool(free_tier_offer.claim_offer())})
+ return _ok(rid, {"claimed": bool(free_tier_offer.claim_offer(params.get("kind") or free_tier_offer.TRIAL))})
except Exception as e: # the RPC boundary: report it, never crash the dispatcher
logger.warning("free_tier.claim_nudge failed", exc_info=True)
return _err(rid, 5093, str(e))
diff --git a/website/docs/user-guide/features/tool-search.md b/website/docs/user-guide/features/tool-search.md
index 75ed580ff3..81c1017e7a 100644
--- a/website/docs/user-guide/features/tool-search.md
+++ b/website/docs/user-guide/features/tool-search.md
@@ -183,9 +183,12 @@ tools:
```
Connectors need a Nous identity. You do not have to sign in for one: if you
-have no Nous account, the first `manage_connections` action (or opening the
-desktop Connectors page) creates a free Nous identity for you, on every
-surface. It does not change your model provider: outside builds that ship
+have no Nous account, the first `manage_connections` action (or connecting an
+app from the desktop Connectors page) creates a free Nous identity for you, on
+every surface. Opening the Connectors page creates none: until an identity
+exists it lists the hosted apps from the Nous Portal's public catalog, which
+needs no sign-in.
+It does not change your model provider: outside builds that ship
the Nous free tier it is used for connectors and managed tools only. Until it exists, `tool_search` sends nothing to the
gateway and its result says that hosted connectors are not set up yet and
that `manage_connections` sets them up. To opt out, set: