KDCube
← Our Journal

This is the missing shape for custom services. Suppose there is a service S1: it has its own users, its own OAuth/OIDC authority, and an API that KDCube tools need to call. The user asks an agent to use S1. The tool reaches the point where it needs an S1 token. If the token does not exist yet, the tool does not fake it, fail as an anonymous request, or ask the developer for a private secret. It returns a Connection Hub action: connect S1, approve the claim, then retry.

THE TOOL CALLS YOUR SERVICE AS THE USER User & agent asks the agent to use S1 chat turn in progress S1 tool reaches the point it needs an S1 access token Connection Hub delegated to KDCube resolves the connected S1 account for this user S1 API call runs as the user with their own S1 token Action in chat connect / approve / reconnect / pick account usable credential no usable credential yet No shared system key, no platform token, no secret in code — the tool resolves the user's own S1 credential, or returns the action to obtain it.
The round trip: the tool asks Connection Hub for a connected S1 account, and gets back either a usable credential or the exact action to obtain one.

The boundary

This is delegated to KDCube: an external provider issues a credential so KDCube can enter that provider on the user's behalf. It is one of three Connection Hub directions, and it is worth keeping them apart.

Platform authority

An external identity proves who the KDCube user is — a Cognito or Google-backed application login. This grants platform roles.

Delegated by KDCube

KDCube issues a bearer credential so automation can enter KDCube — an admin grants an automation scoped REST/MCP access.

Delegated to KDCube

An external provider issues a credential so KDCube can enter that provider — Gmail, Slack, or a custom S1 OAuth/OIDC service.

That distinction matters. An S1 token is not a KDCube login. It does not give the holder platform roles. It is only a provider credential that KDCube may use after the current KDCube request is already authorized.

The three-layer model

Connection Hub separates the provider, the connector app, and the user's actual connected account. One KDCube deployment configures a single S1 connector app, while many users connect their own S1 accounts — and one user can connect several accounts of the same provider (several Slack workspaces, or several tenants of a custom service).

ONE CONNECTOR, MANY USER ACCOUNTS Provider shared · public config authorize · token · userinfo endpoints · default scopes profile mapping: subject · email · display name · workspace adapter: oidc.generic / oauth2.generic Connector app config + secret ref client_id · allowed_claims — the claim ceiling this app may request client_secret_ref → secrets descriptor, never in public config User connected accounts per-user · in user secrets provider subject · label / email / workspace · approved claims credential health access + refresh token → user secrets one per external service many users share one connector
Config and the claim ceiling are shared; the secret is a reference; the live credential is per user.

What the service must provide

For a generic OAuth/OIDC service, S1 must provide the normal OAuth surface: an authorization endpoint, a token endpoint, an optional userinfo endpoint, a client id and client secret, provider scopes, a stable account subject, and refresh-token support for long-lived automation. It also registers KDCube's shared delegated-to-KDCube callback URL:

https://<PUBLIC_HOST>/api/integrations/bundles/<TENANT>/<PROJECT>/connection-hub@1-0/public/delegated_to_kdcube_oauth_callback

The callback URL is exact. The host, tenant, project, application id, route, and callback path all have to match the URL KDCube sends in the OAuth request. If S1 is backed by Cognito, Auth0, Okta, or another OIDC provider, that is fine — KDCube sees it as a provider account authority for S1, not as the KDCube platform authority.

The Connection Hub config

The operator registers the provider and connector app under the Connection Hub application config.

bundles:
  version: "1"
  items:
    - id: connection-hub@1-0
      config:
        connections:
          delegated_to_kdcube:
            enabled: true
            oauth:
              public_base_url: "https://<PUBLIC_HOST>"
            providers:
              s1:
                label: S1
                adapter: oidc.generic
                enabled: true
                oauth:
                  authorize_url: https://s1.example.com/oauth2/authorize
                  token_url: https://s1.example.com/oauth2/token
                  userinfo_url: https://s1.example.com/oauth2/userInfo
                  default_scopes: [openid, email, profile]
                  authorize_params:
                    audience: s1-api
                  profile:
                    subject: sub
                    email: email
                    display_name: name
                    workspace: custom.tenant
                connector_apps:
                  default:
                    label: S1 connector
                    enabled: true
                    client_id: "<S1_CLIENT_ID>"
                    client_secret_ref: connections.delegated_to_kdcube.providers.s1.connector_apps.default.client_secret
                    allowed_claims: [s1:read, s1:write]
                claims:
                  s1:read:
                    label: Read S1
                    description: Read S1 data for the approving user.
                    provider_scopes: [s1.read]
                  s1:write:
                    label: Write S1
                    description: Write S1 data for the approving user.
                    provider_scopes: [s1.write]

The secret lives in the secrets descriptor, not in the public config:

bundles:
  version: "1"
  items:
    - id: connection-hub@1-0
      secrets:
        connections:
          delegated_to_kdcube:
            oauth_state_secret: "<RANDOM_HEX_32_BYTES>"
            providers:
              s1:
                connector_apps:
                  default:
                    client_secret: "<S1_CLIENT_SECRET>"

The adapter is generic. Use oidc.generic when the provider has OIDC identity fields such as sub and usually a userinfo endpoint; use oauth2.generic when the provider is OAuth-only. Provider-specific adapters still make sense for services with non-standard token responses, unusual refresh behavior, or account metadata that cannot be mapped from token/userinfo JSON.

Claims are the contract between tools and provider access

KDCube claims are not provider scopes. A claim (s1:read) is the KDCube consent unit a tool asks for; a scope (s1.read) is what the provider understands. The connector app declares which claims this OAuth app may request — the claim ceiling — and each tool declares the exact claims it needs.

connector_apps:
  default:
    allowed_claims: [s1:read, s1:write]   # the ceiling

tool_claims:
  read_s1_object:
    connections:
      delegated_to_kdcube:
        connected_accounts:
          - provider_id: s1
            connector_app_id: default
            claims: [s1:read]              # exactly what this tool needs

There is no intermediate capability registry here. The connector app defines the claim ceiling, each tool declares the exact provider claims it needs, and Connection Hub enforces both sides.

The runtime flow

At runtime, the tool resolves the credential through the SDK, calls the service, and wraps the whole body so a rejected token becomes a retry rather than a dead end.

from kdcube_ai_app.apps.chat.sdk.integrations.connected_accounts import (
    connected_account_auth_failure,
    resolve_connected_account_claim,
    run_with_connected_account_retry,
)


async def read_s1_object(source, object_id: str, account_id: str | None = None):
    async def _run():
        credential = await resolve_connected_account_claim(
            source,
            provider_id="s1",
            connector_app_id="default",
            claim="s1:read",
            tool_name="s1.read_s1_object",
            account_id=account_id,
        )
        if not credential.ok:
            return credential.error_envelope(where="s1.read_s1_object")

        response = await call_s1_api(
            access_token=credential.access_token,
            object_id=object_id,
        )
        if response.status_code in (401, 403):
            return connected_account_auth_failure(
                credential,
                "S1 rejected the access token",
            )
        return response.json()

    return await run_with_connected_account_retry(
        source,
        where="s1.read_s1_object",
        run=_run,
    )

The retry wrapper handles the practical OAuth failure mode: when the provider rejects the token, it force-refreshes that credential and retries the body once. If the refresh works, the provider result flows back; if the provider still rejects it, the account is marked reconnect_required and the tool returns a Connection Hub reconnect action. That is the difference between a provider integration and a raw API call — the error becomes a user action: connect, approve a missing claim, pick an account, or reconnect.

TWO WAYS A CALL RECOVERS WHEN THE CLAIM IS MISSING First attempt tool needs claim s1:read connect_required claim_upgrade_required Connection Hub user connects S1 and approves the claim Retry — same call credential resolves for this user provider result opens hub retry WHEN THE TOKEN IS REJECTED Token rejected S1 returns 401 / 403 auth failure Force-refresh refresh the credential once Retry once same tool call refresh worked → result reconnect_required refresh retry The retry wrapper refreshes a rejected token once and retries; a second rejection becomes a reconnect action, not a silent failure.
A missing claim recovers through the user's approval; a rejected token recovers through one refresh, then a reconnect action if it still fails.

How this reaches named services and MCP

The same provider-account contract can sit behind a named-service namespace. An external agent — Claude, or generated code — reaches kdcube-services@1-0 under a KDCube delegated credential or a local runtime identity, calls the s1 namespace, and the provider resolves the connected account claim s1:read before calling the S1 API with the user's own token. There are two boundaries:

  • Client → KDCube. Is this client allowed to call this KDCube named-service operation?
  • KDCube → S1. Has the current KDCube user connected S1 and approved the provider claim?

This is how a custom integration becomes agent-ready without giving the agent a provider secret. An external client can connect to the KDCube named-services MCP surface, but the actual S1 token remains a user-scoped connected account inside Connection Hub.

What the user sees

The experience is demand-driven. The tool needs s1:read; no connected account or approved claim exists; chat receives a managed consent envelope with a Connection Hub URL. The user opens Connection Hub, signs into S1, and approves the requested access; Connection Hub stores the account metadata and credential; the user continues, and the tool resolves the S1 credential and succeeds. In the widget this belongs under Delegated to KDCube — the external accounts this user allows KDCube applications and automation to use.

The user is not granting S1 access to KDCube's platform identity. They are granting KDCube permission to use their S1 account for specific KDCube claims.

Why this is useful

This makes custom service integration repeatable — the service owner brings OAuth/OIDC, the operator registers the connector app and claim mapping, the tool developer declares tool_claims, and the user approves only when a tool actually needs the provider account. Named services can then expose the integration to agents through the same Connection Hub boundary.

No shared provider token. No provider secret in tool code. No tool-specific OAuth machinery copied into every application.