Skip to main content
An integration is a connector to an external service that exposes one or more tools — callable actions an LLM node, an Agent node, the AI Composer, and the Assistant can invoke. Every integration ships from the public modulex-integrations package as a Python package, and the ModuleX runtime discovers it through the modulex.tools entry-point group and loads it at startup. This page is the end-to-end author’s guide: the directory the runtime expects, the manifest it reads, the @tool function contract it executes, how you register your integration so the runtime can find it, and how to test it locally. Two companion pages go deeper on the contracts referenced throughout: the manifest and schema contract (every field, type, default, and the six auth schema variants) and the @tool function contract (decorator order, auth-parameter conventions, output models, and error patterns). If instead of authoring a package you want to plug in an external server, see custom MCP servers.
The modulex-integrations package targets Python 3.12+ and is built with Hatchling + hatch-vcs (the version is derived from git tags; there is no central version file). Its three always-installed core dependencies are pydantic>=2.5.0, httpx>=0.25.0, and langchain-core>=0.3.0. Everything a specific tool needs beyond those is per-tool.

Before you start

1

Pick a name

Choose a lowercase, snake-case integration name that matches the pattern ^[a-z][a-z0-9_]*$ — for example github, google_ads, mongodb_atlas. This one string is your directory name, your entry-point key, your manifest name, and the credential/catalog key the runtime uses. They must all be identical.
2

Clone the repository and install the dev extras

The dev extras group pulls in pytest, pytest-asyncio, pytest-httpx, ruff, and mypy, plus a hand-maintained mirror of several vendor SDKs so type-checking can import them.
git clone https://github.com/ModuleXAI/modulex-integrations.git
cd modulex-integrations
pip install -e ".[dev]"
pytest
3

Copy an existing integration as your starting point

The fastest path is to copy a folder under src/modulex_integrations/tools/ (for example github for a token-auth REST API, or exa for an API-key service), then edit the manifest, tools, outputs, and tests for your service. Every integration ships the identical file set, so reviews are mechanical.

Anatomy of an integration

Every integration lives at src/modulex_integrations/tools/<name>/ and ships the same fixed set of files. The contract is enforced by pydantic types in schema.py with extra="forbid" everywhere — a typo in a manifest fails at import time, not at runtime.
The per-integration directory layout
src/modulex_integrations/tools/<name>/
├── __init__.py        # re-exports `manifest` + `TOOLS`
├── manifest.py        # IntegrationManifest instance bound to the name `manifest`
├── tools.py           # LangChain @tool functions (one per action)
├── outputs.py         # pydantic response models (output schema derived from these)
├── dependencies.toml  # per-integration runtime deps
├── README.md          # 5-section strict template
└── tests/
    ├── __init__.py    # empty
    └── test_<name>.py # ≥1 happy-path test per @tool

manifest.py

Builds one IntegrationManifest instance named manifest: metadata, actions, and auth_schemas. The single source of truth for the integration’s identity and how it authenticates.

tools.py

The per-action args_schema input models and the @tool functions, plus an auth-header helper. The @tool function names must match the manifest actions.

outputs.py

Typed pydantic output models, one per action, used as @tool return annotations. The runtime derives each action’s output schema from these.

__init__.py

The public surface the runtime imports: it re-exports manifest and a TOOLS tuple of the @tool objects.

dependencies.toml

A dependencies = [...] list of the vendor SDKs this tool needs (often empty when only httpx is used).

README.md

A five-section human doc (title, Authentication, Tools, Limits & Quotas, Maintainer). CI enforces the section list.

The two symbols the runtime reads

The runtime imports your integration’s __init__.py and reads exactly two names. Nothing else in the module is part of the runtime contract.
manifest
IntegrationManifest
required
An IntegrationManifest instance (re-exported from manifest.py) describing the integration’s metadata, actions, and auth schemas. See the manifest and schema contract for every field.
TOOLS
tuple[StructuredTool, ...]
required
A tuple of LangChain StructuredTool objects, one per action — the @tool-decorated functions from tools.py. Both the runtime and the docs export script read getattr(module, "TOOLS", ()), so the attribute must be a tuple named exactly TOOLS.
A minimal __init__.py re-exports both symbols:
src/modulex_integrations/tools/<name>/__init__.py
"""<Display name> integration — discovered by the modulex runtime via the
``modulex.tools`` entry point declared in the root ``pyproject.toml``.

The runtime imports this module, then reads:

- ``manifest`` — an IntegrationManifest describing metadata, actions, and auth schemas.
- ``TOOLS`` — a tuple of LangChain StructuredTool objects, one per action.
"""
from modulex_integrations.tools.<name>.manifest import manifest
from modulex_integrations.tools.<name>.tools import (
    create_thing,
    list_things,
)

TOOLS = (
    list_things,
    create_thing,
)

__all__ = ["TOOLS", "create_thing", "list_things", "manifest"]

Step 1 — Write the manifest

manifest.py builds exactly one IntegrationManifest instance bound to the module-level name manifest. The model rejects unknown fields (extra="forbid"), so import will fail if you add a field that is not in the schema. There is no output_schema, docs_url, features, rate_limits, pricing, or metadata field — those belong in the README, not the manifest.

IntegrationManifest fields

integration_type
Literal["tool"]
default:"tool"
Always tool — the only allowed value. (LLM and knowledge providers are not authored here; they stay in the modulex backend.)
name
string
required
The integration name. Must match the pattern ^[a-z][a-z0-9_]*$ and be identical to the directory name and the entry-point key (for example github).
display_name
string
required
The human-facing label shown in the catalog and UI (for example GitHub).
description
string
required
A one-line description of what the integration does.
version
string
default:"1.0.0"
The per-integration manifest version — not the package version.
author
string
default:"ModuleX"
The author/maintainer name.
Logo reference. Convention is modulex:<name>-themed (for example modulex:github-themed).
app_url
string | null
default:"null"
The provider’s homepage URL.
categories
string[]
default:"[]"
Free-form category strings used to group the integration in the catalog (for example ["Developer Tools & Infrastructure", "version-control"]).
actions
ActionDefinition[]
default:"[]"
One ActionDefinition per callable action. Each action’s name must match a @tool function name in TOOLS.
auth_schemas
AuthSchema[]
default:"[]"
A list from the discriminated union of the six auth variants (keyed on auth_type). An integration may expose more than one — GitHub ships OAuth2 plus a personal-access-token bearer schema. See the six auth schema variants.
A trimmed manifest for a token-auth REST integration looks like this:
src/modulex_integrations/tools/<name>/manifest.py
from modulex_integrations.schema import (
    ActionDefinition,
    BearerTokenAuthSchema,
    EnvVar,
    IntegrationManifest,
    ParameterDef,
    SuccessIndicators,
    TestEndpoint,
)

manifest = IntegrationManifest(
    name="acme",
    display_name="Acme",
    description="Acme task and project management",
    version="1.0.0",
    author="ModuleX",
    logo="modulex:acme-themed",
    app_url="https://acme.example.com",
    categories=["Productivity"],
    actions=[
        ActionDefinition(
            name="list_tasks",
            description="List tasks in a project",
            parameters={
                "project_id": ParameterDef(type="string", description="Project id", required=True),
                "limit": ParameterDef(type="integer", description="Max tasks to return", default=30),
            },
        ),
    ],
    auth_schemas=[
        BearerTokenAuthSchema(
            display_name="Personal Access Token",
            description="Use your Acme API token",
            setup_environment_variables=[
                EnvVar(
                    name="ACME_TOKEN",
                    display_name="API Token",
                    description="Acme personal access token",
                    sensitive=True,
                    sample_format="acme_xxxxxxxx",
                    about_url="https://acme.example.com/settings/tokens",
                ),
            ],
            test_endpoint=TestEndpoint(
                url="https://api.acme.example.com/me",
                method="GET",
                headers={"Authorization": "Bearer {token}"},
                success_indicators=SuccessIndicators(status_codes=[200], response_fields=["id"]),
                cost_level="free",
            ),
        ),
    ],
)
For the full set of auth variants (oauth2, bearer_token, api_key, modulex_key, custom, internal), the OAuthConfig, EnvVar, TestEndpoint, SuccessIndicators, and BasicAuthSpec fields, and the only_for_custom versus inject_into_auth_data behavior, see the manifest and schema contract.

Step 2 — Write the @tool functions

tools.py holds one async def per action, each carrying a paired pydantic args_schema input model and a typed pydantic return annotation from outputs.py. Every function uses the same mandatory two-decorator stack.
The decorator order is non-negotiable: @tool is the outer decorator (applied last, closest to the call site) and @serialize_pydantic_return is the inner decorator. Reverse them and @tool receives the typed pydantic-returning function, the dict coercion never fires, and the runtime’s json.dumps() boundary raises TypeError: Object of type ... is not JSON serializable.
src/modulex_integrations/tools/<name>/tools.py
from __future__ import annotations

from typing import Any

import httpx
from langchain_core.tools import tool
from pydantic import BaseModel, Field

from modulex_integrations import serialize_pydantic_return
from modulex_integrations.tools.acme.outputs import ListTasksOutput


class ListTasksInput(BaseModel):
    auth_type: str = Field(description="Authentication type (oauth2, bearer_token)")
    auth_data: dict[str, Any] = Field(description="Authentication data containing tokens")
    project_id: str = Field(description="Project id")
    limit: int = Field(default=30, description="Max tasks to return")


@tool(args_schema=ListTasksInput)       # OUTER — applied last, closest to the call site
@serialize_pydantic_return              # INNER — wraps the raw function
async def list_tasks(
    auth_type: str,
    auth_data: dict[str, Any],
    project_id: str,
    limit: int = 30,
) -> ListTasksOutput:                    # return annotation = the output model
    """List tasks in a project."""
    headers = _get_auth_headers(auth_type, auth_data)
    async with httpx.AsyncClient() as client:
        response = await client.get(
            f"https://api.acme.example.com/projects/{project_id}/tasks",
            headers=headers,
            params={"limit": limit},
        )
        response.raise_for_status()
        data = response.json()
    return ListTasksOutput(success=True, tasks=data.get("tasks", []))
Use from __future__ import annotations at the top of both tools.py and outputs.py. The backend reads each @tool’s return annotation via typing.get_type_hints(...) (not raw __annotations__) precisely to resolve these stringified PEP 563 annotations into the real pydantic class.

Auth parameter conventions

The runtime injects credentials at call time and strips the credential fields from the schema the model sees, so the LLM never reads tokens. There are two conventions; pick the one that matches your auth schemas.
For oauth2 and bearer_token schemas, the first two parameters are auth_type: str and auth_data: dict[str, Any]. A module-level helper builds the provider headers, branching on auth_type:
def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]:
    headers = {"Accept": "application/json"}
    if auth_type == "oauth2":
        token = auth_data.get("access_token")
        if token:
            headers["Authorization"] = f"Bearer {token}"
    elif auth_type == "bearer_token":
        token = auth_data.get("token")
        if token:
            headers["Authorization"] = f"Bearer {token}"
    return headers

Output models

outputs.py defines one typed pydantic model per action. Each model subclasses a shared base with model_config = ConfigDict(extra="forbid"). The only field guaranteed across every integration is success: bool; an error: str | None field exists only on integrations whose tools return a failure payload rather than raising.
src/modulex_integrations/tools/<name>/outputs.py
from __future__ import annotations

from pydantic import BaseModel, ConfigDict, Field


class _Base(BaseModel):
    model_config = ConfigDict(extra="forbid")


class TaskSummary(_Base):
    id: str | None = None
    title: str | None = None
    status: str | None = None


class ListTasksOutput(_Base):
    success: bool                                    # required on every output model
    tasks: list[TaskSummary] = Field(default_factory=list)
    error: str | None = None                         # present only on inline-error integrations
The backend derives each action’s LLM-facing output schema by calling model_json_schema() on the resolved return type at startup — which is why the return annotation must stay the pydantic class even though @serialize_pydantic_return coerces the runtime value to a dict.

Choose one error pattern

Pick the pattern that fits how your upstream service signals failure and apply it consistently across the integration.
Call response.raise_for_status() after each request and only ever construct success=True models; HTTP errors propagate as exceptions. Output models have no error field. Use this when the upstream API returns standard HTTP error status codes.
The upstream returns HTTP 200 with a body like {"ok": false, "error": "..."}. Read the JSON, check the flag, and on failure return the output model with success=False and error set; on success leave error as None. Do not call raise_for_status() — inspect the body instead.
Wrap every call in try/except so non-2xx responses, timeouts, and unexpected exceptions all surface as success=False with error set, never raising. Pre-validate the credential and set explicit per-endpoint timeouts. Use this when you want the tool to always return a structured result.
For the fully annotated end-to-end examples (GitHub create_issue, Slack, Exa), the exact serialize_pydantic_return behavior, and how the annotation becomes the output schema, see the @tool function contract.

Step 3 — Register the entry point

The ModuleX runtime discovers integrations through the Python modulex.tools entry-point group, not by scanning the filesystem. Add one line for your integration to the root pyproject.toml. The key is your integration name; the value is the <name> package path (not the .tools module — the runtime appends .tools itself when it needs the tool module).
pyproject.toml
[project.entry-points."modulex.tools"]
github = "modulex_integrations.tools.github"
slack = "modulex_integrations.tools.slack"
acme = "modulex_integrations.tools.acme"
After installing or upgrading the package, the runtime enumerates entry_points(group="modulex.tools"), imports each module, reads its manifest and TOOLS, converts them into the legacy action-dict shape it expects, and caches the result for the process lifetime. Restart the worker to pick up a freshly installed package version.
The docs/CI export script (scripts/export_manifests.py) uses a separate discovery mechanism: it walks the filesystem for tools/*/manifest.py and uses the entry-point table only as a cross-check. So a tool can exist on disk and export to the docs catalog even before its entry point is registered — but the runtime will not load it until the entry-point line is present. Always add the entry-point line.

Declare runtime dependencies

List any vendor SDKs your tool imports in its dependencies.toml. Leave it as an empty list when the tool only uses httpx (already a core dependency).
src/modulex_integrations/tools/<name>/dependencies.toml
dependencies = ["acme-sdk>=2.0.0"]
Do not rely on pip install extras to pull these in today. The all extra resolves to an empty list and there are no per-tool extras groups ([github], [slack], and the like are not defined), because the script that assembles dependencies.toml files into installable extras does not yet exist. Installing modulex-integrations[all] or modulex-integrations[acme] installs only the core package — pip will warn about the unknown extra. Until the assemble step lands, install modulex-integrations and then install each tool’s SDKs manually per its dependencies.toml. See installing integrations.

Step 4 — Write the README

Every integration’s README.md follows the same five-section template, in order. CI enforces the section list, and the export script splits the README into the prose that keeps each generated catalog page from being thin content.
1

Title + summary

The display name as an H1, plus a one-to-two sentence summary naming the upstream API and its base URL.
2

Authentication

One subsection per auth method: the environment variables, scopes, and token/authorize URLs.
3

Tools

A markdown table of name | description | required params, plus the note that the runtime injects the auth parameters.
4

Limits & Quotas

Rate limits and the integration’s error-model behavior (raise versus inline ok:false versus try/except).
5

Maintainer

Your GitHub handle, or “ModuleX core team”.

Step 5 — Test it locally

Each integration ships at least one happy-path test per @tool, plus a manifest sanity check. HTTP tools use pytest-httpx to mock responses; tools that call a vendor SDK stub the client with unittest.mock.patch. Tools are invoked exactly as the runtime invokes them — via LangChain .ainvoke({...}) with a single input dict — and they return a dict (because @serialize_pydantic_return coerced the model).
src/modulex_integrations/tools/<name>/tests/test_<name>.py
from __future__ import annotations

from typing import Any

import pytest

from modulex_integrations.tools.acme import TOOLS, list_tasks, manifest

API = "https://api.acme.example.com"
_AUTH: dict[str, Any] = {"auth_type": "bearer_token", "auth_data": {"token": "acme-fake"}}


def _args(**extra: Any) -> dict[str, Any]:
    return dict(_AUTH, **extra)


class TestManifest:
    def test_actions_match_tools_tuple(self) -> None:
        assert {a.name for a in manifest.actions} == {t.name for t in TOOLS}


@pytest.mark.asyncio
async def test_list_tasks(httpx_mock):  # type: ignore[no-untyped-def]
    httpx_mock.add_response(
        method="GET",
        url=f"{API}/projects/p1/tasks?limit=30",
        json={"tasks": [{"id": "t1", "title": "Ship docs", "status": "open"}]},
    )
    result = await list_tasks.ainvoke(_args(project_id="p1"))
    assert result["success"] is True
    assert result["tasks"][0]["id"] == "t1"
Run the suite, then the linters and type checker before opening a pull request:
pytest
🎬 MEDIA PLACEHOLDER · MX-MEDIA-4170 · [IMAGE] [IMAGE_DESCRIPTION]: The author-to-runtime lifecycle of a custom ModuleX integration. [IMAGE_DETAILS]: A left-to-right flow with five labeled stages: (1) author the directory (manifest.py / tools.py / outputs.py / __init__.py), (2) register the modulex.tools entry point in pyproject.toml, (3) pip install / package build, (4) runtime startup enumerates entry points and reads manifest + TOOLS, (5) the tool becomes callable from the LLM node, Agent node, Composer, and Assistant. Show the docs/CI filesystem-walk export branch as a side path feeding the catalog. 16:9, light and dark variants, ModuleX brand palette, no UI chrome.

Validate the contract before you ship

Because every schema model uses extra="forbid", an unknown or misspelled field fails when the module is imported. Importing manifest.py (or running pytest, which imports it) surfaces these errors immediately — there is no runtime-only failure mode for schema typos.
The integration name must be identical across the directory name, the manifest name, the entry-point key, and the <name> segment of the entry-point value. Each ActionDefinition.name must equal a @tool function name listed in TOOLS. The test assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} catches drift.
The args_schema is the authoritative list of what the tool accepts; the manifest parameters is descriptive. If a parameter reaches the upstream API, it should appear on the args_schema. Avoid documenting a parameter in the manifest that the function does not accept.
Use one of Pattern A / B / C consistently, set explicit timeouts on outbound calls, and never let the LLM see credential fields — the runtime strips auth_type, auth_data, api_key, token, access_token, and similar from the model-facing schema.

Next steps

Manifest & schema contract

Every manifest field, type, and default; the six auth schema variants; OAuthConfig, EnvVar, TestEndpoint, and BasicAuthSpec.

@tool function contract

Decorator order, the two auth conventions, output models, the three error patterns, and fully annotated GitHub, Slack, and Exa examples.

Custom MCP servers

Connect an external MCP server and use its tools in ModuleX without packaging an integration.

Contributing

How to open a pull request against modulex-integrations and what reviewers check.