feat(connectors): a guest identity is created on the first connector use, on every surface

A user with no Nous sign-in could use connectors only inside the store desktop
build, where the boot bootstrap mints a guest because HERMES_GUEST_ONBOARDING=1.
On the plain CLI, TUI, messaging gateway and other desktop builds the
manage_connections tool was hidden, so connectors were unreachable.

Now any user with no Nous identity sees manage_connections, and the guest
identity is created when they first ask for an app:

- connectors_available() also admits a user with no identity who may get one
  (guest_identity_pending: nous.guest is not false, no bearer, no terminal
  mint refusal). No network, so the check_fn stays cheap.
- managed.managed_client(), the real gateway client behind every hosted
  manage_connections action, calls ensure_guest_identity() first. The mint is
  synchronous and bounded by the existing 5 s budget; a failure returns the
  free-tier copy with its wait.
- The desktop Connectors page RPCs (both account gates) create it the same way:
  opening the page is the user asking for apps, and the page has no rows to
  click without an identity.
- The model's read legs (tool_search, tool_describe, connector calls) never
  mint. With no identity they make no request and answer not_set_up, whose
  hint names manage_connections. Before, the same case said "sign in to Nous
  again".

The free model is unchanged: guest_enabled() still requires the launch gate,
so a guest created for connectors never becomes the inference provider. Only
the connectors path reads the new guest_allowed() (nous.guest), through
managed_tool_gateway._read_nous_provider_state. Boot minting is unchanged.

The setup chat's apps card no longer mints on its own; its rows come from the
connectors.list RPC, which now creates the identity.
This commit is contained in:
alt-glitch authored and Siddharth Balyan committed 2026-10-13 01:35:42 +05:30
1 parent ff2d02f998
commit d7830c6f42
15 files changed
+221 -66

No files matched your search

+23 -15
View File
@@ -1,7 +1,9 @@
"""Nous free-tier identity: the ``anonymous`` auth method of the ``nous`` provider.
The identity is created in exactly one place, at boot (``hermes_cli.free_tier_bootstrap``), and only
while ``HERMES_GUEST_ONBOARDING=1`` (see ``guest_enabled``). The bootstrap mints an anonymous Nous
The identity is created in two places: at boot (``hermes_cli.free_tier_bootstrap``) while
``HERMES_GUEST_ONBOARDING=1`` (see ``guest_enabled``), and on the first hosted connector action for
anyone without a Nous identity (``tools.connectors.gateway.config.ensure_guest_identity``, gated only
by ``guest_allowed``). Either mints an anonymous Nous
account (``POST /api/anonymous/create``); its ``anon_`` credential is later exchanged for short-lived
JWTs (``POST /api/anonymous/token``). The result is persisted as the singleton ``providers.nous``. In
the resolver ladder (``resolve_provider``) an existing free-tier identity sits directly above the
@@ -155,15 +157,18 @@ def anon_failure_copy(code: str, *, retry_after: Any = None) -> str:
def guest_enabled() -> bool:
"""The free tier is on for this process: the launch gate is set AND ``nous.guest`` (default
True) has not switched it off."""
if (os.environ.get(GUEST_ONBOARDING_ENV) or "").strip() != "1":
return False
"""The free tier is on for this process: the launch gate is set AND :func:`guest_allowed`."""
return (os.environ.get(GUEST_ONBOARDING_ENV) or "").strip() == "1" and guest_allowed()
def guest_allowed() -> bool:
"""``nous.guest`` (default True) has not switched guests off. Enough for a guest used only for
connectors; the free model and the boot mint also need the launch gate (:func:`guest_enabled`)."""
try:
from hermes_cli.config import load_config_readonly
nous_cfg = load_config_readonly().get("nous")
except Exception as exc: # config unreadable: keep today's behaviour (no guest) rather than mint
logger.debug("guest: config unreadable, treating nous.guest as false: %s", exc)
except Exception: # config unreadable: keep today's behaviour (no guest) rather than mint
logger.debug("guest: config unreadable, treating nous.guest as false", exc_info=True)
return False
if not isinstance(nous_cfg, dict):
return True
@@ -624,18 +629,21 @@ def _reconcile_and_provision(*, timeout_seconds: float) -> Optional[dict[str, An
def ensure_portal_identity(
*, explicit: bool, timeout_seconds: float = GUEST_MINT_TIMEOUT_SECONDS, force: bool = False,
for_connectors: bool = False,
) -> Optional[dict[str, Any]]:
"""Make sure this profile has a Nous identity (guest or account); mint a guest only if the shared
store has none. Returns the ``providers.nous`` state, or None (disabled / failed once already).
``explicit`` is required and must be True: the only callers are the boot bootstrap
(``free_tier_bootstrap.run_bootstrap``), the desktop's ``free_tier.provision`` retry, the setup
chat's apps card (``setup_choose_tool._connectors_closed``, one attempt), and the
dead-credential replacements (``auth_nous.resolve_nous_runtime_credentials``,
``managed_tool_gateway._replace_dead_guest_token``). Nothing creates an identity as a side effect
of reading status, resolving a provider or fetching a connector bearer (NS-845 Q1.2).
(``free_tier_bootstrap.run_bootstrap``), the desktop's ``free_tier.provision`` retry, the first
hosted connector action (``tools.connectors.gateway.config.ensure_guest_identity``), and the
dead-credential replacements
(``auth_nous.resolve_nous_runtime_credentials``, ``managed_tool_gateway._replace_dead_guest_token``).
Nothing creates an identity as a side effect of reading status, resolving a provider or fetching a
connector bearer (NS-845 Q1.2).
Order: ``guest_enabled`` gate -> reconcile with the shared store -> mint. Locks are taken profile
Order: gate -> reconcile with the shared store -> mint. The gate is ``guest_enabled``, or only
``guest_allowed`` with ``for_connectors=True``: a connectors guest never opens the free model. Locks are taken profile
first, then shared, matching every other Nous path. Blocking, bounded by ``timeout_seconds``; the bootstrap puts it on its own thread.
A failed mint is memoised with a cooldown (``MintFailure``): until it passes, and for a
@@ -646,7 +654,7 @@ def ensure_portal_identity(
"""
if not explicit:
raise ValueError("ensure_portal_identity: only explicit creators may call this (explicit=True)")
if not guest_enabled():
if not (guest_allowed() if for_connectors else guest_enabled()):
return None
failure = _mint_failure_for_profile()
if failure and not force and not current_nous_state() and time.monotonic() < failure.not_before:
+5 -2
View File
@@ -1,4 +1,5 @@
"""Serve-start bootstrap for the Nous free tier: the ONE place a free-tier identity is created.
"""Serve-start bootstrap for the Nous free tier: the one place a free-tier identity that carries
inference is created.
Every Hermes process that may need the free tier runs this once at boot (``hermes serve`` on a
daemon thread beside the other background boots; the CLI first-run guard synchronously). It
@@ -6,7 +7,9 @@ inventories credentials cheap-first, creates the identity only when the launch g
(:func:`hermes_cli.anon_auth.guest_enabled`), resolves which provider carries inference, records
the answer in process memory, and tells every connected client with one ``setup.ready`` event.
Nothing else mints. ``free_tier.status`` and ``setup.status`` read the record; provider resolution
Besides this boot path, only the first hosted connector action mints, and only for a user with no
Nous identity (``tools.connectors.gateway.config.ensure_guest_identity``; that guest never opens the
free model). ``free_tier.status`` and ``setup.status`` read the record; provider resolution
never reaches the portal; a dead credential is replaced by the explicit re-mint in
``auth_nous.resolve_nous_runtime_credentials``. Ruling: NS-845 Q1.2 (recorded on NS-847).
"""
+1 -1
View File
@@ -1092,7 +1092,7 @@ def _auth_store_logged_in(auth_file: Path, registry, strict_profile_scope: bool)
def _has_any_provider_configured(*, strict_profile_scope: bool = False) -> bool:
"""Check if at least one inference provider is usable. Never creates one: the Nous free tier
counts only once its identity exists, and the boot bootstrap (``hermes_cli.free_tier_bootstrap``)
is the only thing that creates it; ``cmd_chat`` runs the bootstrap before asking.
is the only thing that creates one that carries inference; ``cmd_chat`` runs the bootstrap before asking.
``strict_profile_scope``: the caller has bound a NAMED profile's home and
secret scope and wants an answer for that profile only — launch-process
+1 -1
View File
@@ -295,7 +295,7 @@ async def _lifespan(app: FastAPI):
threading.Thread(target=_boot_local_runtime, daemon=True, name="local-runtime-boot").start()
# Nous free tier: the ONE place its identity is created. Inventories credentials, mints only
# Nous free tier: the one boot-time creator of its identity. Inventories credentials, mints only
# when HERMES_GUEST_ONBOARDING=1, records the answer for setup.status / free_tier.status and
# broadcasts `setup.ready`. Off-thread so a slow portal never delays the socket; the desktop's
# first setup.status waits on the record (bounded) instead.
@@ -0,0 +1,81 @@
"""A guest identity for connectors is created on the first hosted connector action.
Contracts, for a user with no Nous identity on a surface without the free-tier launch gate (plain
CLI, TUI, messaging gateway):
- ``manage_connections`` is offered, and its first hosted action creates exactly one guest whose
token is connectors-only; later actions create none, and the free model is never selected
- tool_search sends nothing to the portal or gateway and points the model at ``manage_connections``
- ``nous.guest: false`` keeps the old answer: no tool, no account
Driven through the fake NAS (``anon_portal``) with Hermes' real client code.
"""
import json
import pytest
from hermes_cli import anon_auth
from tests.hermes_cli.anon_portal import install_portal
@pytest.fixture
def nas(monkeypatch, tmp_path):
fake = install_portal(monkeypatch, tmp_path)
monkeypatch.delenv("HERMES_GUEST_ONBOARDING")
return fake
def test_first_hosted_action_creates_one_connectors_only_guest(nas, monkeypatch):
from tools.connectors.gateway import client
from tools.connectors.gateway.config import connectors_available
from tools.connectors.tool import manage_connections
from tools.managed_tool_gateway import read_nous_access_token
bearers = []
class Gateway:
"""Reads the bearer the way the real gateway client does on every request."""
def list_connectors(self, *, timeout=None):
bearers.append(read_nous_access_token())
return [{"connector": "gmail", "enabled": True, "connected": False}]
monkeypatch.setattr(client, "ConnectorClient", Gateway)
assert connectors_available()
assert nas.creates() == 0
for _ in range(2):
result = json.loads(manage_connections({"action": "status"}, connectors_available=connectors_available))
assert result["connectors"][0]["connector"] == "gmail"
assert nas.creates() == 1
assert [r["body"].get("purpose") for r in nas.token_requests] == ["connectors"]
assert bearers and all(anon_auth.is_connectors_only(b) for b in bearers)
assert anon_auth.has_guest() and not anon_auth.has_free_tier_account()
def test_tool_search_without_an_identity_sends_nothing(nas, monkeypatch):
from tools.connectors.tool import MANAGE_CONNECTIONS_SCHEMA
from tools.tool_search import dispatch_tool_search
def no_client():
raise AssertionError("no gateway client may be built before an identity exists")
monkeypatch.setattr("tools.connectors.gateway.client.ConnectorClient", no_client)
tool_defs = [{"type": "function", "function": MANAGE_CONNECTIONS_SCHEMA}]
result = json.loads(dispatch_tool_search({"queries": ["gmail send email"]}, current_tool_defs=tool_defs))
assert result["connectors"]["status"] == "not_set_up"
assert "manage_connections" in result["connectors"]["hint"]
assert nas.calls == []
def test_guest_opt_out_keeps_connectors_closed(nas):
from hermes_constants import get_hermes_home
from tools.connectors.gateway.config import connectors_available, ensure_guest_identity
(get_hermes_home() / "config.yaml").write_text("nous:\n guest: false\n", encoding="utf-8")
assert not connectors_available()
assert ensure_guest_identity() is None
assert nas.calls == []
+2
View File
@@ -20,6 +20,8 @@ _LAZY_EXPORTS: dict[str, str] = {
"connectors_available": "tools.connectors.gateway.config",
"dispatch_connector_batch": "tools.connectors.dispatch",
"dispatch_connector_call": "tools.connectors.dispatch",
"ensure_guest_identity": "tools.connectors.gateway.config",
"guest_identity_pending": "tools.connectors.gateway.config",
"is_connector_name": "tools.connectors.gateway.names",
"manage_connections": "tools.connectors.tool",
}
+12 -1
View File
@@ -4,7 +4,7 @@ import logging
from dataclasses import dataclass, field, replace as dataclass_replace
from typing import Any, Callable, Optional, Sequence
from tools.connectors.gateway.config import connectors_available
from tools.connectors.gateway.config import connectors_available, guest_identity_pending
from tools.connectors.gateway.errors import GatewayAuthError, GatewayUnavailable, ToolGatewayError
from tools.connectors.gateway.merge import fill_remote_failure, splice_remote_results
from tools.connectors.gateway.names import parse_connector_name, vendor_slug_candidates
@@ -13,8 +13,14 @@ logger = logging.getLogger(__name__)
SIGN_IN_EXPIRED = "sign_in_expired"
UNREACHABLE = "unreachable"
# No Nous identity yet: the first hosted manage_connections action creates one.
NOT_SET_UP = "not_set_up"
NOT_SET_UP_MESSAGE = ("Hosted connectors are not set up for this user yet. "
"Call manage_connections to connect the app first; that sets them up.")
__all__ = [
"NOT_SET_UP",
"NOT_SET_UP_MESSAGE",
"SIGN_IN_EXPIRED",
"UNREACHABLE",
"ConnectorLeg",
@@ -35,6 +41,8 @@ _TOKEN_REJECTED_CODES = frozenset({"UNAUTHORIZED", "INVALID_TOKEN", "TOKEN_EXPIR
def _leg_failure(exc: Exception) -> Optional[str]:
if isinstance(exc, GatewayAuthError) and exc.code == NOT_SET_UP:
return NOT_SET_UP
if isinstance(exc, GatewayAuthError):
if exc.status == 401 or str(exc.code).upper() in _TOKEN_REJECTED_CODES:
return SIGN_IN_EXPIRED
@@ -43,8 +51,11 @@ def _leg_failure(exc: Exception) -> Optional[str]:
def _default_client_factory():
"""The real client for the model's read and execute legs. These never create an identity."""
from tools.connectors.gateway.client import ConnectorClient
if guest_identity_pending():
raise GatewayAuthError(NOT_SET_UP_MESSAGE, code=NOT_SET_UP)
return ConnectorClient()
+36 -3
View File
@@ -15,6 +15,8 @@ __all__ = [
"MAX_CALLS_PER_DISPATCH",
"ConnectorConfig",
"connectors_available",
"ensure_guest_identity",
"guest_identity_pending",
"load_config",
]
@@ -87,8 +89,8 @@ def connectors_available(
The one gate for the connectors surface, and the tool's ``check_fn``: outside it the tool is
not in the schema at all, so the model never narrates a gateway 404 to a user the portal has
not enabled. Free-tier identities are always in; accounts are in only when the portal says so
via the token claim."""
not enabled. Free-tier identities are always in, and so is a user with no identity who may get
one; accounts are in only when the portal says so via the token claim."""
try:
resolved_loader = config_loader or load_config
if not resolved_loader().enabled:
@@ -98,7 +100,7 @@ def connectors_available(
from tools.managed_tool_gateway import _read_nous_provider_state
# Availability must not mint or refresh an identity.
if is_guest_state(_read_nous_provider_state()):
if is_guest_state(_read_nous_provider_state()) or guest_identity_pending():
return True
entitlement_check = managed_tools_rolled_out
@@ -108,6 +110,37 @@ def connectors_available(
return False
def guest_identity_pending() -> bool:
"""No Nous identity or bearer yet, and a guest one may be created (``nous.guest`` is not false
and no earlier mint was refused for good). No network: this feeds the ``check_fn``."""
from hermes_cli.anon_auth import guest_allowed, last_mint_failure
from tools.managed_tool_gateway import _read_nous_provider_state, peek_nous_access_token
if not guest_allowed() or _read_nous_provider_state() is not None or peek_nous_access_token():
return False
failure = last_mint_failure()
return not failure or bool(failure["retryable"])
def ensure_guest_identity() -> Optional[str]:
"""Create the guest identity on the first hosted connector action, synchronously.
Returns None when an identity exists or was just created, else the failure copy for the model
(rate limit or portal down, with its wait)."""
if not guest_identity_pending():
return None
from hermes_cli.anon_auth import ensure_portal_identity, last_mint_failure
from hermes_cli.auth_constants import AuthError
try:
if ensure_portal_identity(explicit=True, for_connectors=True) is not None:
return None
except AuthError as exc:
return str(exc)
# Still inside the cooldown of an earlier failure: report that one.
return str((last_mint_failure() or {}).get("error") or "Connectors could not be set up. Try again shortly.")
def operation_session_key(session_id: Optional[str]) -> str:
"""The key an operation is registered under: the gateway session key the RPCs look up by
(``HERMES_SESSION_KEY``), falling back to the agent's session id where no gateway bound one.
+13 -3
View File
@@ -6,7 +6,7 @@ import time
from typing import Any, Callable, Dict, List, Optional, Tuple
from tools.connectors.contract import Actor, SettleReason, TargetState, allowed
from tools.connectors.gateway.config import operation_session_key
from tools.connectors.gateway.config import ensure_guest_identity, operation_session_key
from tools.connectors.gateway.errors import RateLimited, ToolGatewayError
from tools.connectors.operation import ConnectionOperation, DetachedOperation, IllegalTransition, Target
from tools.connectors.run import Kind, run_operation
@@ -43,9 +43,17 @@ NOTE = (
)
def managed_client():
from tools.connectors.gateway.client import ConnectorClient
GUEST_SETUP_FAILED = "GUEST_SETUP_FAILED"
def managed_client():
"""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."""
from tools.connectors.gateway.client import ConnectorClient
from tools.connectors.gateway.errors import ToolGatewayError
if setup_error := ensure_guest_identity():
raise ToolGatewayError(setup_error, code=GUEST_SETUP_FAILED, retryable=True)
return ConnectorClient()
@@ -378,6 +386,8 @@ def run_managed_action(
)
except Exception as exc:
logger.debug("manage_connections %s failed: %s", action, exc)
if getattr(exc, "code", None) == GUEST_SETUP_FAILED:
return tool_error(str(exc))
return tool_error(
f"The connector gateway request failed: {exc}. "
"If this persists, the user can manage connections in the Nous Portal."
+9 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
from typing import Any, Dict, Iterable, List, Optional, Tuple
from tools.connectors.gateway.bridge import SIGN_IN_EXPIRED, UNREACHABLE
from tools.connectors.gateway.bridge import NOT_SET_UP, NOT_SET_UP_MESSAGE, SIGN_IN_EXPIRED, UNREACHABLE
from tools.connectors.gateway.names import format_connector_name, is_connector_name, vendor_slug_candidates
from tools.tool_search_catalog import CatalogEntry, _fn, _tokenize
@@ -16,11 +16,18 @@ def connections_in_scope(tool_defs: Iterable[dict[str, Any]]) -> bool:
def connectors_unavailable(failure: str, *, verb: str,
names: Optional[list[str]] = None) -> dict[str, Any]:
if failure == NOT_SET_UP:
field: dict[str, Any] = {"status": NOT_SET_UP, "hint": (
f"{NOT_SET_UP_MESSAGE} Hosted apps (Gmail, Calendar, Notion and more) are then searchable. "
"If the request is better served by the tools above, use those instead.")}
if names:
field["names"] = names
return field
hint = (f"Hosted connector tools could not be {verb} right now. "
"Do not conclude the app is missing.")
if failure == SIGN_IN_EXPIRED:
hint += " The user must sign in to Nous again."
field: dict[str, Any] = {"status": "unavailable", "reason": failure, "hint": hint}
field = {"status": "unavailable", "reason": failure, "hint": hint}
if names:
field["names"] = names
return field
+6 -5
View File
@@ -37,7 +37,7 @@ def auth_json_path():
def _read_nous_provider_state() -> Optional[dict]:
"""The profile's Nous state, or None. A free-tier identity counts only while the free tier is on:
"""The profile's Nous state, or None. A free-tier identity counts only while guests are allowed:
with ``nous.guest: false`` it is invisible here, so no cached or refreshed token of it is ever
attached to a request.
@@ -51,9 +51,9 @@ def _read_nous_provider_state() -> Optional[dict]:
nous_provider = get_provider_auth_state("nous")
if not isinstance(nous_provider, dict):
return None
from hermes_cli.anon_auth import guest_enabled, is_guest_state
from hermes_cli.anon_auth import guest_allowed, is_guest_state
if is_guest_state(nous_provider) and not guest_enabled():
if is_guest_state(nous_provider) and not guest_allowed():
return None
return nous_provider
except Exception:
@@ -86,7 +86,7 @@ def read_nous_access_token() -> Optional[str]:
"""Read a Nous Subscriber OAuth access token from auth store or env override.
A read: with no Nous identity there is no bearer and the answer is None. The free-tier identity
is created by the boot bootstrap (``hermes_cli.free_tier_bootstrap``), never on a token-read
is created by the boot bootstrap or the first hosted connector action, never on a token-read
path (NS-845 Q1.2). A retired free-tier credential IS replaced here, once: that is the explicit
dead-credential rule, shared with inference.
"""
@@ -125,7 +125,8 @@ def _replace_dead_guest_token(dead_state: dict, code: str = "anon_credential_dea
if code == ANON_ACCOUNT_LOCKED:
return None
try:
if ensure_portal_identity(explicit=True) is None:
# The identity this replaces served connectors and managed tools: the connectors gate applies.
if ensure_portal_identity(explicit=True, for_connectors=True) is None:
return None
return _clean(resolve_nous_access_token(refresh_skew_seconds=_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS))
except Exception as exc:
+3 -15
View File
@@ -214,22 +214,10 @@ def _name_rows() -> Optional[list]:
def _connectors_closed() -> Optional[str]:
from tools.connectors.gateway.config import connectors_available, load_config
from tools.connectors.gateway.config import connectors_available
if connectors_available():
return None
from hermes_cli.anon_auth import current_nous_state, ensure_portal_identity, guest_enabled
# Connectors ride a Nous identity. A boot-time guest mint can be refused (rate limited) and stop retrying, so
# the card makes one attempt of its own; the mint memo's cooldown still holds it back from the portal.
if load_config().enabled and not current_nous_state() and guest_enabled():
try:
ensure_portal_identity(explicit=True)
except Exception: # health: allow BLE001 -- one best-effort mint; on any failure the card says why it is closed
logger.info("setup_choose: no guest identity for connectors", exc_info=True)
if connectors_available():
return None
return _NO_CONNECTORS
# A user with no Nous identity is open too: the first connect creates the guest.
return None if connectors_available() else _NO_CONNECTORS
def _plugins_closed() -> Optional[str]:
+7 -4
View File
@@ -78,12 +78,15 @@ def _session_owner(rid, owner):
def _account_gate_closed(rid):
from tools.connectors import connectors_available
from tools.connectors import connectors_available, ensure_guest_identity
from tui_gateway.contracts.connectors import ConnectorErrorReason
if connectors_available():
return None
return _connector_rpc_error(rid, 4031, ConnectorErrorReason.connectors_unavailable, "Connectors are not available.")
if not connectors_available():
return _connector_rpc_error(rid, 4031, ConnectorErrorReason.connectors_unavailable, "Connectors are not available.")
# Opening the Connectors page is the user asking for apps: create the guest identity here.
if setup_error := ensure_guest_identity():
return _connector_rpc_error(rid, 5034, ConnectorErrorReason.connector_request_failed, setup_error)
return None
def _parse_params(rid, params, model):
+2 -6
View File
@@ -19,14 +19,10 @@ def _account_method(params_model=None, *, invalid="", invalid_reason=ConnectorEr
def decorate(fn):
def handler(rid, params):
from pydantic import ValidationError
from tools.connectors import connectors_available
from tools.connectors.gateway.errors import GatewayAuthError
from tui_gateway.contracts.connectors import ConnectorErrorReason
if not connectors_available():
return _connector_rpc_error(
rid, 4031, ConnectorErrorReason.connectors_unavailable, "Connectors are not available."
)
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:
@@ -157,8 +157,8 @@ tools:
## Connectors (remote tools)
When you are signed in to the Nous Portal, the bridge additionally reaches
**connectors** — remote tools served by the managed tool gateway. They are
The bridge also reaches **connectors**: remote tools served by the managed tool
gateway. They are
never registered locally: `tool_search` sends each query to the gateway, adds
the gateway's hits to the local catalog as documents (tagged
`source: "connectors"`, named `connectors__<connector>__<tool>`), and ranks
@@ -182,9 +182,22 @@ tools:
# behaves exactly as if the feature didn't exist
```
Signed out (or when the gateway does not serve connectors for your
account), everything above is invisible: local search behaves exactly as
described in the rest of this page, with no errors shown to the model.
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 is used for connectors and managed tools only and never changes
your model provider. 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:
```yaml
nous:
guest: false
```
With that setting, or when the gateway does not serve connectors for a
signed-in account, everything above is invisible: local search behaves exactly
as described in the rest of this page, with no errors shown to the model.
A connector call that needs an account you haven't linked returns a
`CONNECTION_REQUIRED` error. The `manage_connections` tool lists connectors and
@@ -192,9 +205,8 @@ their connection state and starts an authorization: in the desktop app the call
shows a card, blocks until each app is connected or skipped, and reports the
outcomes; elsewhere it returns a connect link per app for the user to open.
Disconnecting an account is done by the user in the Portal. The same tool also installs, enables and authorizes
local MCP servers from the catalog (targets with `mcp: true`), so it is
present whether or not you are signed in; only the managed-connector actions
need the sign-in.
local MCP servers from the catalog (targets with `mcp: true`); those actions
never create a Nous identity.
You can link more than one account of the same app, each with its own name
(see [Multiple connector accounts](./tool-gateway.md#multiple-connector-accounts)).