> ## Documentation Index
> Fetch the complete documentation index at: https://docs.modulex.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Build an integration

> Author your own ModuleX integration end to end: the per-integration directory layout, the manifest, the @tool function contract, entry-point registration in the modulex.tools group, and how to test it locally before the runtime loads it.

export const MediaEmbed = ({id, type = 'screenshot', caption = '', ext, ratio = '16 / 9'}) => {
  const isVideo = type === 'video' || type === 'app_video';
  const resolvedExt = ext || (isVideo ? 'mp4' : type === 'screenshot' ? 'webp' : 'svg');
  const src = 'https://media.modulex.dev/' + id + '.' + resolvedExt;
  const [status, setStatus] = useState('loading');
  const [isDev, setIsDev] = useState(false);
  const [inView, setInView] = useState(false);
  const boxRef = useRef(null);
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const h = window.location.hostname;
    setIsDev(h === 'localhost' || h === '127.0.0.1' || h.endsWith('.mintlify.app'));
  }, []);
  useEffect(() => {
    if (inView) return;
    if (typeof IntersectionObserver === 'undefined') {
      setInView(true);
      return;
    }
    const el = boxRef.current;
    if (!el) return;
    const io = new IntersectionObserver(entries => {
      if (entries.some(e => e.isIntersecting)) {
        setInView(true);
        io.disconnect();
      }
    }, {
      rootMargin: '300px'
    });
    io.observe(el);
    return () => io.disconnect();
  }, [inView]);
  if (status === 'missing') {
    if (!isDev) return null;
    return <div style={{
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
      gap: '0.4rem',
      padding: '1rem 1.25rem',
      margin: '1.25rem 0',
      width: '100%',
      aspectRatio: ratio,
      boxSizing: 'border-box',
      border: '1px dashed rgba(128,128,128,0.45)',
      borderRadius: '0.75rem',
      background: 'rgba(128,128,128,0.06)',
      color: 'currentColor',
      fontSize: '0.85rem',
      lineHeight: 1.45
    }}>
        <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: '0.5rem',
      opacity: 0.75
    }}>
          <span aria-hidden="true">🎬</span>
          <code style={{
      fontSize: '0.75rem'
    }}>{id}</code>
          <span style={{
      fontSize: '0.65rem',
      textTransform: 'uppercase',
      letterSpacing: '0.04em',
      padding: '0.1rem 0.4rem',
      borderRadius: '0.4rem',
      background: 'rgba(128,128,128,0.18)'
    }}>
            {type}
          </span>
        </div>
        <div style={{
      opacity: 0.9
    }}>{caption || 'Media not uploaded yet.'}</div>
        <div style={{
      fontSize: '0.7rem',
      opacity: 0.5
    }}>
          Upload to R2 as <code>{id}.{resolvedExt}</code> — preview only, hidden in production.
        </div>
      </div>;
  }
  const mediaStyle = {
    display: status === 'loaded' ? 'block' : 'none',
    width: '100%',
    height: 'auto',
    borderRadius: '0.75rem'
  };
  const media = isVideo ? <video src={inView ? src : undefined} autoPlay loop muted playsInline preload="metadata" onLoadedData={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} /> : <img src={inView ? src : undefined} alt={caption} onLoad={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} />;
  return <figure style={{
    margin: '1.25rem 0'
  }}>
      <div ref={boxRef} style={status === 'loaded' ? {
    width: '100%'
  } : {
    width: '100%',
    aspectRatio: ratio,
    borderRadius: '0.75rem',
    background: 'rgba(128,128,128,0.06)'
  }}>
        {media}
      </div>
      {status === 'loaded' && caption ? <figcaption style={{
    marginTop: '0.5rem',
    textAlign: 'center',
    fontSize: '0.85rem',
    opacity: 0.7
  }}>
          {caption}
        </figcaption> : null}
    </figure>;
};

An integration is a connector to an external service that exposes one or more **tools** — callable actions an [LLM node](/workflow-builder/nodes/llm), an [Agent node](/workflow-builder/nodes/agent), the [AI Composer](/concepts/ai-composer), and the [Assistant](/assistant/overview) 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](/integrations/building/manifest-schema) (every field, type, default, and the six auth schema variants) and the [@tool function contract](/integrations/building/tool-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](/integrations/building/custom-mcp).

<Note>
  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.
</Note>

## Before you start

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.

    ```bash theme={null}
    git clone https://github.com/ModuleXAI/modulex-integrations.git
    cd modulex-integrations
    pip install -e ".[dev]"
    pytest
    ```
  </Step>

  <Step title="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.
  </Step>
</Steps>

## 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.

```text The per-integration directory layout theme={null}
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
```

<CardGroup cols={2}>
  <Card title="manifest.py" icon="file-json">
    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.
  </Card>

  <Card title="tools.py" icon="code">
    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`.
  </Card>

  <Card title="outputs.py" icon="brackets-curly">
    Typed pydantic output models, one per action, used as `@tool` return annotations. The runtime derives each action's output schema from these.
  </Card>

  <Card title="__init__.py" icon="box">
    The public surface the runtime imports: it re-exports `manifest` and a `TOOLS` tuple of the `@tool` objects.
  </Card>

  <Card title="dependencies.toml" icon="list">
    A `dependencies = [...]` list of the vendor SDKs this tool needs (often empty when only `httpx` is used).
  </Card>

  <Card title="README.md" icon="book-open">
    A five-section human doc (title, Authentication, Tools, Limits & Quotas, Maintainer). CI enforces the section list.
  </Card>
</CardGroup>

### 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.

<ResponseField name="manifest" type="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](/integrations/building/manifest-schema) for every field.
</ResponseField>

<ResponseField name="TOOLS" type="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`.
</ResponseField>

A minimal `__init__.py` re-exports both symbols:

```python src/modulex_integrations/tools/<name>/__init__.py theme={null}
"""<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

<ParamField path="integration_type" type="Literal[&#x22;tool&#x22;]" default="tool">
  Always `tool` — the only allowed value. (LLM and knowledge providers are not authored here; they stay in the `modulex` backend.)
</ParamField>

<ParamField path="name" type="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`).
</ParamField>

<ParamField path="display_name" type="string" required>
  The human-facing label shown in the catalog and UI (for example `GitHub`).
</ParamField>

<ParamField path="description" type="string" required>
  A one-line description of what the integration does.
</ParamField>

<ParamField path="version" type="string" default="1.0.0">
  The per-integration manifest version — not the package version.
</ParamField>

<ParamField path="author" type="string" default="ModuleX">
  The author/maintainer name.
</ParamField>

<ParamField path="logo" type="string | null" default="null">
  Logo reference. Convention is `modulex:<name>-themed` (for example `modulex:github-themed`).
</ParamField>

<ParamField path="app_url" type="string | null" default="null">
  The provider's homepage URL.
</ParamField>

<ParamField path="categories" type="string[]" default="[]">
  Free-form category strings used to group the integration in the catalog (for example `["Developer Tools & Infrastructure", "version-control"]`).
</ParamField>

<ParamField path="actions" type="ActionDefinition[]" default="[]">
  One `ActionDefinition` per callable action. Each action's `name` must match a `@tool` function name in `TOOLS`.
</ParamField>

<ParamField path="auth_schemas" type="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](/integrations/building/manifest-schema).
</ParamField>

<Expandable title="ActionDefinition and ParameterDef">
  Each action is an `ActionDefinition`. The action's output shape is **not** declared here — it is derived from the matching `@tool` function's return annotation at backend startup, so the manifest never carries an `output_schema`.

  <ParamField path="ActionDefinition.name" type="string" required>
    The action name. Must match `^[a-z][a-z0-9_]*$` and equal a `@tool` function name in `TOOLS`.
  </ParamField>

  <ParamField path="ActionDefinition.description" type="string" required>
    A one-line description of the action.
  </ParamField>

  <ParamField path="ActionDefinition.parameters" type="dict[str, ParameterDef]" default="{}">
    A mapping of parameter name to `ParameterDef`.
  </ParamField>

  Each `ParameterDef` describes one parameter:

  <ParamField path="ParameterDef.type" type="string" required>
    One of `string`, `integer`, `number`, `boolean`, `array`, or `object`.
  </ParamField>

  <ParamField path="ParameterDef.description" type="string" required>
    A one-line description of the parameter.
  </ParamField>

  <ParamField path="ParameterDef.default" type="Any" default="null">
    The default value when the parameter is omitted.
  </ParamField>

  <ParamField path="ParameterDef.required" type="boolean" default="false">
    Whether the parameter must be supplied. Note this defaults to `false` — the opposite of `EnvVar.required`, which defaults to `true`.
  </ParamField>

  <Warning>
    The manifest `parameters` are **not** the authoritative list of what a tool accepts — the `@tool` function's `args_schema` is. In several shipped integrations a parameter exists on the `args_schema` (and reaches the upstream API) but is omitted from the manifest. Keep both in sync; the `args_schema` is the callable surface of truth.
  </Warning>
</Expandable>

A trimmed manifest for a token-auth REST integration looks like this:

```python src/modulex_integrations/tools/<name>/manifest.py theme={null}
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](/integrations/building/manifest-schema).

## 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.

<Warning>
  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`.
</Warning>

```python src/modulex_integrations/tools/<name>/tools.py theme={null}
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", []))
```

<Note>
  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.
</Note>

### 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.

<Tabs>
  <Tab title="auth_type + auth_data (token-based)">
    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`:

    ```python theme={null}
    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
    ```
  </Tab>

  <Tab title="api_key (key-based)">
    For `api_key` and `modulex_key` schemas, take the credential parameter directly — for example `api_key: str` as the second parameter — instead of the `auth_type`/`auth_data` pair. The runtime auto-fills any function parameter whose name matches a key in the resolved credential (`api_key`, `token`, `access_token`). Add a non-empty check before any HTTP call:

    ```python theme={null}
    if not api_key or not api_key.strip():
        return SearchOutput(success=False, error="API key is empty.")
    headers = {"x-api-key": api_key}
    ```
  </Tab>
</Tabs>

### 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.

```python src/modulex_integrations/tools/<name>/outputs.py theme={null}
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.

<AccordionGroup>
  <Accordion title="Pattern A — raise on error (GitHub style)">
    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.
  </Accordion>

  <Accordion title="Pattern B — inline ok:false (Slack style)">
    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.
  </Accordion>

  <Accordion title="Pattern C — try/except wrapping (Exa style)">
    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.
  </Accordion>
</AccordionGroup>

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](/integrations/building/tool-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).

```toml pyproject.toml theme={null}
[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.

<Note>
  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.
</Note>

### 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).

```toml src/modulex_integrations/tools/<name>/dependencies.toml theme={null}
dependencies = ["acme-sdk>=2.0.0"]
```

<Warning>
  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](/integrations/install).
</Warning>

## 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.

<Steps>
  <Step title="Title + summary">
    The display name as an H1, plus a one-to-two sentence summary naming the upstream API and its base URL.
  </Step>

  <Step title="Authentication">
    One subsection per auth method: the environment variables, scopes, and token/authorize URLs.
  </Step>

  <Step title="Tools">
    A markdown table of `name | description | required params`, plus the note that the runtime injects the auth parameters.
  </Step>

  <Step title="Limits & Quotas">
    Rate limits and the integration's error-model behavior (raise versus inline `ok:false` versus try/except).
  </Step>

  <Step title="Maintainer">
    Your GitHub handle, or "ModuleX core team".
  </Step>
</Steps>

## 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).

```python src/modulex_integrations/tools/<name>/tests/test_<name>.py theme={null}
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:

<CodeGroup>
  ```bash All tests theme={null}
  pytest
  ```

  ```bash One integration theme={null}
  pytest src/modulex_integrations/tools/acme/tests/
  ```

  ```bash Lint & type-check theme={null}
  ruff check src tests
  mypy src/modulex_integrations
  ```
</CodeGroup>

<MediaEmbed id="MX-MEDIA-4170" type="image" caption={"The author-to-runtime lifecycle of a custom ModuleX integration."} />

## Validate the contract before you ship

<AccordionGroup>
  <Accordion title="Import the manifest in isolation">
    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.
  </Accordion>

  <Accordion title="Confirm names line up four ways">
    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.
  </Accordion>

  <Accordion title="Keep args_schema and manifest parameters consistent">
    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.
  </Accordion>

  <Accordion title="Pick a license-compatible upstream and a single error pattern">
    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.
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Manifest & schema contract" icon="file-json" href="/integrations/building/manifest-schema">
    Every manifest field, type, and default; the six auth schema variants; `OAuthConfig`, `EnvVar`, `TestEndpoint`, and `BasicAuthSpec`.
  </Card>

  <Card title="@tool function contract" icon="code" href="/integrations/building/tool-contract">
    Decorator order, the two auth conventions, output models, the three error patterns, and fully annotated GitHub, Slack, and Exa examples.
  </Card>

  <Card title="Custom MCP servers" icon="server" href="/integrations/building/custom-mcp">
    Connect an external MCP server and use its tools in ModuleX without packaging an integration.
  </Card>

  <Card title="Contributing" icon="git-pull-request" href="/resources/contributing">
    How to open a pull request against `modulex-integrations` and what reviewers check.
  </Card>
</CardGroup>
