openapi: 3.1.0
info:
  title: Nucleo AI Tools API
  version: v1
  summary: List and run the Nucleo tools of one module for an AI client acting on behalf of a user.
  description: |
    Every Nucleo module (Brain, Catalog, Commerce, Intelligence) exposes the same two endpoints:
    `GET /api/v1/ai-tools` returns the tools the signed-in person may use in a company, and
    `POST /api/v1/ai-tools/{name}` runs one of them and returns an MCP `CallToolResult`.

    This is the contract the **Nucleo MCP connector** (`https://mcp.nucleoplatform.com`) uses behind the
    scenes. Most integrations should simply connect an MCP client to the connector (see the *MCP
    connector* API). Call these endpoints directly only if you build your own agent runtime and need
    tool definitions and results without MCP.

    Authentication: an OAuth access token from Nucleo issued to a **self-registered client** (it carries
    the scope `nucleo.ai_tools`). These endpoints accept only such tokens, and such tokens are accepted
    only here (plus `userinfo` and the company context). Every call runs inside one company
    (`X-Nucleo-Company`) with the person's own permissions. Delete tools are not exposed; the few writes
    that cannot be undone (merges, forgetting a memory) carry `destructiveHint: true`.
x-nucleo:
  product: ai-tools
  module: platform
  audience: [ai-assistant, partner]
  stability: beta
  format: json
  order: 3
servers:
  - url: https://{host}
    description: The module's API host. Tools of each module live on its own host.
    variables:
      host:
        default: api-catalog.nucleoplatform.com
        enum:
          - api-brain.nucleoplatform.com
          - api-catalog.nucleoplatform.com
          - api-commerce.nucleoplatform.com
          - api-intelligence.nucleoplatform.com
        description: |
          `api-brain` = Brain (CRM, inbox, knowledge, documents, CMS), `api-catalog` = Catalog (PIM, DAM),
          `api-commerce` = Commerce (customer care, POS, orders), `api-intelligence` = Intelligence (sales
          analytics). The same list, per company, is returned by `GET /api/mcp/context` (`api_base_url`).
security:
  - nucleoOAuth: [nucleo.ai_tools]
tags:
  - name: Context
    description: |
      Served by Nucleo Core (`auth.nucleoplatform.com`): which companies the person can work in, which
      modules each company exposes to AI clients and their API hosts, and the active company.
  - name: Tools
    description: |
      Served by each module. Tool names have **no module prefix** here (`search_orders`); the MCP
      connector adds one (`commerce_search_orders`). The full catalogue per module is in the MCP
      connector API (`x-nucleo-tools`).
paths:
  /api/mcp/context:
    servers:
      - url: https://auth.nucleoplatform.com
        description: Nucleo Core
    get:
      operationId: getAiContext
      summary: Get companies, modules and active company
      tags: [Context]
      description: |
        The companies of the signed-in person in canonical order, with the modules each one can reach
        through AI clients (`connector_enabled`) and the base URL of each module's API, plus the
        **active company** used by the MCP connector.

        `active_company_source` tells how it was chosen: `claim` (the `org_id` hint you passed),
        `only` (the person has one company), `preference` (saved with `PUT /api/mcp/company` or the
        connector tool `nucleo_switch_company`), `default` (first company).

        A module with `connector_enabled: false` has been switched off for AI clients by that company
        (or has no tools API): do not call it.
      parameters:
        - name: org_id
          in: query
          required: false
          description: Preferred company id; used as active company only if the person belongs to it.
          schema: { type: integer }
          example: 7
      responses:
        "200":
          description: Context.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AiContext" }
              examples:
                acme:
                  value:
                    user: { id: 42, name: Giulia Rossi, email: giulia.rossi@acme.example, language: it, is_internal: false }
                    companies:
                      - id: 7
                        slug: acme
                        name: Acme Apparel
                        is_internal: false
                        is_demo: false
                        disabled_sections: [pos]
                        modules:
                          - { slug: catalog, name: Catalog, role: editor, api_base_url: https://api-catalog.nucleoplatform.com, connector_enabled: true }
                          - { slug: commerce, name: Commerce, role: viewer, api_base_url: https://api-commerce.nucleoplatform.com, connector_enabled: true }
                          - { slug: intelligence, name: Intelligence, role: viewer, api_base_url: https://api-intelligence.nucleoplatform.com, connector_enabled: false }
                    active_company_id: 7
                    active_company_source: only
        "401": { $ref: "#/components/responses/Unauthenticated" }
  /api/mcp/company:
    servers:
      - url: https://auth.nucleoplatform.com
        description: Nucleo Core
    put:
      operationId: setActiveCompany
      summary: Set the active company
      tags: [Context]
      description: |
        Saves the active company of the person for AI clients (one value per person, shared by every AI
        client they connect, including the MCP connector) and returns the updated context.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [organization_id]
              properties:
                organization_id: { type: integer, description: "Company id from `companies[].id`." }
            examples:
              acme:
                value: { organization_id: 7 }
      responses:
        "200":
          description: Updated context (same shape as `GET /api/mcp/context`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AiContext" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403":
          description: The person has no access to that company.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                forbidden:
                  value: { error: company_forbidden, message: You have no access to this company. }
        "422":
          description: Validation failed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValidationError" }
              examples:
                missing:
                  value:
                    message: The organization id field is required.
                    errors: { organization_id: [The organization id field is required.] }
  /api/v1/ai-tools:
    get:
      operationId: listAiTools
      summary: List the tools available in a company
      tags: [Tools]
      x-rate-limit: { limit: 120, window: "1m", scope: "per user" }
      description: |
        The tools the person may use in the company of `X-Nucleo-Company`, already filtered by their
        permissions and roles in that module, sorted by name. Definitions do not change between a list
        and a call unless the company or the person's permissions change.

        **Caching**: responses carry `ETag` and `Cache-Control: private, max-age=60`; send the ETag back
        in `If-None-Match` to get `304 Not Modified`.

        **Company errors** (`400 company_required`, `403 company_forbidden`, `409 company_not_ready`)
        mean the module is not usable for this person in this company: hide it.

        Catalog only: a company can have several catalogs. `X-Nucleo-Store` (id or slug) picks one;
        without it the first ready catalog the person can access is used, and `company.store` says which.

        **Module differences.** On Brain, company errors are
        plain `{"message": "..."}` bodies (`400` no company, `403` no access, `404` Brain not set up for the
        company — no `409`), and it does not echo `X-Nucleo-Request-Id`. Intelligence checks the company
        before the contract version.

        Rate limit: 120 requests per minute per user on Catalog, Commerce and Intelligence
        (separate bucket from tool calls). Brain applies no specific limit today.
      parameters:
        - $ref: "#/components/parameters/Company"
        - $ref: "#/components/parameters/Language"
        - $ref: "#/components/parameters/Version"
        - $ref: "#/components/parameters/RequestId"
        - $ref: "#/components/parameters/Store"
        - name: If-None-Match
          in: header
          required: false
          schema: { type: string }
          example: '"5f1d3b0c9a4e2b7d8c6f1a0e3b2d4c5a6f7e8d9c"'
      responses:
        "200":
          description: Tool list.
          headers:
            ETag:
              schema: { type: string }
            Cache-Control:
              schema: { type: string, example: "private, max-age=60" }
            X-Nucleo-Request-Id:
              description: Echo of the request id (or a generated one). Not sent by Brain.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ToolList" }
              examples:
                commerce:
                  value:
                    version: 1
                    module: commerce
                    company: { id: 7, slug: acme, name: Acme Apparel }
                    language: en
                    instructions: "Nucleo Commerce: Customer care (internal tickets between Customer Service, Logistics and the other teams; customer requests from the helpdesk, for the Customer Service team only), POS (points of sale, on-hand stock, inbound deliveries, transfers and returns) and Orders (the OMS: orders, customers, returns, shipments, sellable stock, routing, anomalies, fulfillment KPIs). Ticket codes look like TK-0029. Start from search_tickets or list_customer_requests (search_orders for orders), then read the detail. Everything here is read-only: nothing changes data, and reading a ticket does not mark it as read."
                    tools:
                      - name: get_order
                        title: Get order
                        description: "Orders (Nucleo OMS). One order in full: customer, address, items, ship-from location, holds, shipments with tracking, returns and anomalies."
                        inputSchema:
                          type: object
                          properties:
                            order: { type: string, maxLength: 120, description: "Order number (e.g. #1042) or id." }
                          required: [order]
                        permission: oms.read
                        side_effects: read
                        annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
                        tags: [oms, orders]
                    generated_at: "2026-10-04T09:15:00+02:00"
        "304":
          description: Not modified (`If-None-Match` matched).
        "400": { $ref: "#/components/responses/CompanyRequired" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/CompanyForbidden" }
        "404":
          description: Brain only — Brain is not set up for this company.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                brain: { value: { message: Hub is not provisioned for this company. } }
        "406": { $ref: "#/components/responses/UnsupportedVersion" }
        "409": { $ref: "#/components/responses/CompanyNotReady" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /api/v1/ai-tools/{name}:
    post:
      operationId: callAiTool
      summary: Run a tool
      tags: [Tools]
      x-rate-limit: { limit: 120, window: "1m", scope: "per user" }
      description: |
        Runs one tool with `arguments` (validated against its `inputSchema`) and returns an MCP
        `CallToolResult`:
        - `content[0].text` — readable result (JSON text, cut at 50,000 characters with a
          "truncated" note: refine the query);
        - `structuredContent` — the same result as JSON;
        - `isError: true` — a **domain error** the model can act on (record not found, no data source,
          a write that needs confirmation in the Nucleo app…). HTTP status stays `200`.

        Protocol errors use HTTP statuses: `404 unknown_tool`, `403 permission_denied` (+ `permission`),
        `422 invalid_arguments`, and the company errors of the list endpoint.

        **Writes.** Commerce and Intelligence tools are read-only. Brain and Catalog have write tools
        (`side_effects: write`): announce the change to the user before calling them. Catalog applies
        only small, reversible writes from AI clients (up to 3 writes / 5 products within 10 minutes per
        person and catalog); deletions, whole-object replacements, bulk, storefront-facing changes,
        imports and publishing come back as a preview (`will_do`) with `isError: true` and
        `error: confirmation_required_in_app` — nothing is changed, the user confirms inside Nucleo.

        **Idempotency.** Send a fresh `X-Nucleo-Request-Id` per logical call and reuse it on retries;
        Brain uses it to deduplicate record writes.

        Responses should arrive within 25 seconds; long jobs return an id to poll with a companion tool.

        Brain does not validate arguments with `422`: invalid arguments come back as a domain error
        (`isError: true`).

        Rate limit: 120 calls per minute per user on Catalog, Commerce and Intelligence; Brain applies no
        specific limit today.
      parameters:
        - name: name
          in: path
          required: true
          description: Tool name from the list, without module prefix.
          schema: { type: string, pattern: "^[a-z0-9_]+$", maxLength: 48 }
          example: search_orders
        - $ref: "#/components/parameters/Company"
        - $ref: "#/components/parameters/Language"
        - $ref: "#/components/parameters/Version"
        - $ref: "#/components/parameters/RequestId"
        - $ref: "#/components/parameters/Store"
        - name: X-Nucleo-Client
          in: header
          required: false
          description: Informational `name/version` of your client, used in Nucleo's logs and saved conversations.
          schema: { type: string }
          example: acme-agent/1.4
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ToolCallRequest" }
            examples:
              searchOrders:
                value:
                  arguments: { query: giulia.rossi@acme.example, status: shipped, per_page: 5 }
                  context: { client: { name: acme-agent, version: "1.4" } }
              kpis:
                summary: Intelligence
                value:
                  arguments: { range: 30d, compare: yoy, channel: ecommerce }
      responses:
        "200":
          description: Tool result (success or domain error).
          headers:
            X-Nucleo-Request-Id:
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CallToolResult" }
              examples:
                success:
                  value:
                    content:
                      - type: text
                        text: "{\n    \"total\": 1,\n    \"orders\": [ { \"number\": \"#1042\", \"status\": \"shipped\" } ]\n}"
                    structuredContent:
                      total: 1
                      orders:
                        - { number: "#1042", status: shipped, customer: Giulia Rossi, total: "89.00 EUR", url: https://app.nucleoplatform.com/commerce/orders/1042 }
                    isError: false
                domainError:
                  value:
                    content:
                      - type: text
                        text: "{\n    \"error\": \"not_found\",\n    \"message\": \"Order #1099 not found.\"\n}"
                    structuredContent: { error: not_found, message: "Order #1099 not found." }
                    isError: true
                confirmInApp:
                  summary: Catalog write that needs confirmation
                  value:
                    content:
                      - type: text
                        text: "{ \"error\": \"confirmation_required_in_app\", \"applied\": false, ... }"
                    structuredContent:
                      error: confirmation_required_in_app
                      applied: false
                      message: "Nothing was changed. This change needs an explicit confirmation that only happens inside Nucleo Catalog (deletions, whole-object replacements, bulk or storefront-facing changes, imports, publishing, or more than 3 writes / 5 products within 10 minutes). `will_do` is the preview: show it to the user and ask them to run it with Atomo in Nucleo Catalog, or split it into smaller changes."
                      will_do: { summary: Set price 29.90 EUR on 40 products in price list Retail EU }
                    isError: true
        "400": { $ref: "#/components/responses/CompanyRequired" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403":
          description: Permission denied for this tool, or no access to the module in this company.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                permissionDenied:
                  value: { error: permission_denied, permission: dashboard.private, message: "This dashboard is private: only its owner and Intelligence admins can open it." }
                companyForbidden:
                  value: { error: company_forbidden, message: Nucleo Commerce is not available to you in this company. }
        "404":
          description: Unknown tool, or not visible to this person.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                unknown:
                  value: { error: unknown_tool, message: "Unknown tool: search_invoices" }
        "406": { $ref: "#/components/responses/UnsupportedVersion" }
        "409": { $ref: "#/components/responses/CompanyNotReady" }
        "422":
          description: Arguments do not match the tool's input schema.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                commerce:
                  summary: Commerce / Intelligence (validation errors)
                  value:
                    error: invalid_arguments
                    message: The per page field must not be greater than 50.
                    errors: { per_page: [The per page field must not be greater than 50.] }
                catalog:
                  summary: Catalog
                  value:
                    error: invalid_arguments
                    message: Invalid arguments for this tool.
                    details: [ { field: product, error: required } ]
        "429": { $ref: "#/components/responses/TooManyRequests" }
components:
  securitySchemes:
    nucleoOAuth:
      type: oauth2
      description: |
        Register your client with `POST https://auth.nucleoplatform.com/oauth/register` (RFC 7591), then run
        the authorization code flow with PKCE. Tokens of self-registered clients always include
        `nucleo.ai_tools`. Tokens of Nucleo's own apps are refused here.
      flows:
        authorizationCode:
          authorizationUrl: https://auth.nucleoplatform.com/oauth/authorize
          tokenUrl: https://auth.nucleoplatform.com/oauth/token
          refreshUrl: https://auth.nucleoplatform.com/oauth/token
          scopes:
            openid: OpenID Connect sign-in
            profile: Name and avatar
            email: Email address
            offline_access: Refresh token
            nucleo.ai_tools: Nucleo tools for AI assistants (always granted to self-registered clients)
  parameters:
    Company:
      name: X-Nucleo-Company
      in: header
      required: true
      description: Numeric company id (`companies[].id` from the context, or `orgs[].id` from userinfo).
      schema: { type: string, pattern: "^[1-9][0-9]{0,18}$" }
      example: "7"
    Language:
      name: X-Nucleo-Language
      in: header
      required: false
      description: Language of human-readable texts in results and errors (`en` or `it`, default `en`). Tool descriptions stay in English.
      schema: { type: string, enum: [en, it], default: en }
    Version:
      name: X-Nucleo-AI-Tools-Version
      in: header
      required: false
      description: Contract version. Missing = 1. Any other value → `406`.
      schema: { type: string, enum: ["1"], default: "1" }
    RequestId:
      name: X-Nucleo-Request-Id
      in: header
      required: false
      description: Your id for the call (UUID recommended), echoed back and written in Nucleo's logs. Pattern `^[A-Za-z0-9._:-]{1,128}$`, otherwise a new UUID is used.
      schema: { type: string, maxLength: 128 }
      example: 2f9c1e4a-7b3d-4c5e-9f8a-1b2c3d4e5f6a
    Store:
      name: X-Nucleo-Store
      in: header
      required: false
      description: Catalog only — id or slug of the catalog (store) to use within the company.
      schema: { type: string }
      example: acme-eu
  responses:
    Unauthenticated:
      description: |
        Missing, expired or revoked token — or a token that is not from a self-registered client.
        Body: `{"message":"Unauthenticated."}`; Intelligence answers with `application/problem+json`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Message" }
          examples:
            default: { value: { message: Unauthenticated. } }
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            intelligence: { value: { code: unauthenticated, title: Unauthorized, detail: A valid bearer token is required., status: 401 } }
    CompanyRequired:
      description: "`X-Nucleo-Company` missing or not a positive integer."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            missing: { value: { error: company_required, message: Missing or invalid X-Nucleo-Company header. } }
            brain:
              summary: Brain (message currently in Italian)
              value: { message: Azienda attiva mancante (header X-Nucleo-Company). }
    CompanyForbidden:
      description: The person cannot use this module in this company (no access, or no catalog they belong to).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            forbidden: { value: { error: company_forbidden, message: Nucleo Commerce is not available to you in this company. } }
    CompanyNotReady:
      description: The person has access but the module is not set up yet for this company.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            notReady: { value: { error: company_not_ready, message: "Nucleo Intelligence is not set up yet for this company: open it once in the Nucleo app." } }
    UnsupportedVersion:
      description: Unknown contract version.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            version: { value: { error: unsupported_version, message: Unsupported ai-tools contract version., supported: [1] } }
    TooManyRequests:
      description: |
        More than 120 requests per minute for this user on this endpoint. Default body;
        Intelligence answers with `application/problem+json`.
      headers:
        Retry-After:
          schema: { type: integer, example: 20 }
        X-RateLimit-Limit:
          schema: { type: integer, example: 120 }
        X-RateLimit-Remaining:
          schema: { type: integer, example: 0 }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Message" }
          examples:
            throttled: { value: { message: Too Many Attempts. } }
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            intelligence: { value: { code: error, title: Error, detail: Too Many Attempts., status: 429 } }
  schemas:
    Message:
      type: object
      properties:
        message: { type: string }
    Problem:
      type: object
      properties:
        code: { type: string }
        title: { type: string }
        detail: { type: string }
        status: { type: integer }
    Error:
      type: object
      required: [message]
      description: "Contract error. Brain's company errors carry only `message`."
      properties:
        error:
          type: string
          enum: [company_required, company_forbidden, company_not_ready, unsupported_version, unknown_tool, permission_denied, invalid_arguments]
        message: { type: string, description: In the language of `X-Nucleo-Language` where translated. }
        permission: { type: string, description: "`permission_denied` only." }
        supported: { type: array, items: { type: integer }, description: "`unsupported_version` only." }
        errors:
          type: object
          additionalProperties: { type: array, items: { type: string } }
          description: "`invalid_arguments` on Brain, Commerce and Intelligence (standard validation shape)."
        details:
          type: array
          description: "`invalid_arguments` on Catalog."
          items:
            type: object
            properties:
              field: { type: string }
              error: { type: string }
    ValidationError:
      type: object
      properties:
        message: { type: string }
        errors:
          type: object
          additionalProperties: { type: array, items: { type: string } }
    AiContext:
      type: object
      properties:
        user:
          type: object
          properties:
            id: { type: integer }
            name: { type: string }
            email: { type: string, format: email }
            language: { type: string, enum: [en, it] }
            is_internal: { type: boolean }
        companies:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              slug: { type: string }
              name: { type: string }
              is_internal: { type: boolean }
              is_demo: { type: boolean }
              disabled_sections: { type: array, items: { type: string } }
              modules:
                type: array
                items:
                  type: object
                  properties:
                    slug: { type: string, enum: [brain, catalog, commerce, intelligence] }
                    name: { type: string }
                    role: { type: string }
                    api_base_url: { type: [string, "null"], format: uri }
                    connector_enabled: { type: boolean }
        active_company_id: { type: [integer, "null"] }
        active_company_source: { type: [string, "null"], enum: [claim, only, preference, default, null] }
    ToolDefinition:
      type: object
      required: [name, description, inputSchema, side_effects]
      properties:
        name: { type: string, pattern: "^[a-z][a-z0-9_]{1,47}$" }
        title: { type: string, maxLength: 60 }
        description: { type: string, maxLength: 1024, description: English, written for the model. }
        inputSchema:
          type: object
          description: JSON Schema of `type object` (no `$ref`, `oneOf`/`anyOf`/`allOf` or `additionalProperties`).
        permission: { type: [string, "null"], description: Module permission the tool requires (informational). }
        side_effects: { type: string, enum: [read, write] }
        annotations:
          type: object
          properties:
            readOnlyHint: { type: boolean }
            destructiveHint: { type: boolean, description: "true only for a write that cannot be undone (for example a merge of two records, or forgetting a memory). The gateway passes every hint unchanged to the AI client." }
            idempotentHint: { type: boolean }
            openWorldHint: { type: boolean }
        tags: { type: array, items: { type: string } }
    ToolList:
      type: object
      required: [version, module, tools]
      properties:
        version: { type: integer, const: 1 }
        module: { type: string, enum: [brain, catalog, commerce, intelligence] }
        company:
          type: object
          properties:
            id: { type: [integer, "null"] }
            slug: { type: [string, "null"] }
            name: { type: [string, "null"] }
            store:
              type: object
              description: Catalog only — the catalog in use.
              properties:
                id: { type: integer }
                slug: { type: string }
                name: { type: string }
        language: { type: string }
        instructions: { type: string, description: Module-level guidance for the model (English). }
        tools: { type: array, items: { $ref: "#/components/schemas/ToolDefinition" } }
        generated_at: { type: string, format: date-time }
    ToolCallRequest:
      type: object
      properties:
        arguments:
          type: object
          description: Tool arguments (if omitted, the root keys except `context` are used as arguments).
          additionalProperties: true
        context:
          type: object
          description: Optional, informational.
          properties:
            client:
              type: object
              properties:
                name: { type: string }
                version: { type: string }
            session_id: { type: string }
    CallToolResult:
      type: object
      required: [content, isError]
      properties:
        content:
          type: array
          items:
            type: object
            required: [type]
            properties:
              type: { type: string, enum: [text, resource_link, image] }
              text: { type: string, maxLength: 50000 }
        structuredContent: { type: object, additionalProperties: true }
        isError: { type: boolean }
