Skip to main content
A ModuleX integration exposes its actions as tools: async Python functions wrapped by a LangChain StructuredTool. Each tool pairs a Pydantic input model (args_schema) with a typed Pydantic output model named as the function’s return annotation. This page is the exhaustive reference for that function contract — the exact decorator stack and why its order is non-negotiable, the two ways auth is injected, how the typed output model becomes the LLM-facing output schema, the three error-handling patterns, and three fully worked examples (GitHub, Slack, Exa). For the metadata side of an integration — IntegrationManifest, ActionDefinition, ParameterDef, and the six auth schema variants — see Manifest & schema contract. For the end-to-end anatomy of an integration package, see Build an integration.
The manifest is not the source of truth for what a tool can be called with. The callable surface — every parameter, its type and default — is defined by the tool function’s args_schema, not by the manifest’s parameters. A manifest may list fewer parameters than the function accepts. When the two disagree, the args_schema wins. See The args_schema is the callable surface.

Where a tool lives

Every integration lives at src/modulex_integrations/tools/<name>/ and ships a fixed set of files. The three you write a tool against are tools.py, outputs.py, and __init__.py.
FileRole in the tool contract
tools.pyThe args_schema input models, the auth-header helper, and the @tool functions.
outputs.pyThe typed Pydantic output models — one per action — used as each tool’s return annotation.
manifest.pyBuilds the single IntegrationManifest named manifest. Documented in Manifest & schema contract.
__init__.pyRe-exports manifest and a TOOLS tuple — the only two names the runtime reads.
dependencies.tomlA dependencies = [...] list (often empty).

The module surface the runtime reads

The runtime imports your integration module and reads exactly two names:
  • manifest — one IntegrationManifest instance.
  • TOOLS — a tuple of the @tool objects (LangChain StructuredTools), one per action.
Nothing else in the module is part of the contract. A minimal __init__.py re-exports both:
tools/github/__init__.py
from modulex_integrations.tools.github.manifest import manifest
from modulex_integrations.tools.github.tools import (
    create_issue,
    list_repositories,
    # ... one import per action
)

TOOLS = (create_issue, list_repositories)  # ... all @tool objects

__all__ = ["manifest", "TOOLS", "create_issue", "list_repositories"]
ModuleX discovers integrations through the modulex.tools entry-point group declared in the package’s root pyproject.toml, not by importing files directly. Registering a new integration means adding one entry-point line there. Discovery and registration are covered in Build an integration.

The @tool decorator stack

Every action is an async def wrapped by two decorators, in a fixed order.
The mandatory two-decorator stack
from typing import Any

from langchain_core.tools import tool
from modulex_integrations import serialize_pydantic_return

@tool(args_schema=ListRepositoriesInput)   # OUTER — applied last, closest to the call site
@serialize_pydantic_return                  # INNER — applied first, wraps the raw function
async def list_repositories(
    auth_type: str,
    auth_data: dict[str, Any],
    visibility: str = "all",
    # ... remaining typed parameters with defaults
) -> ListRepositoriesOutput:                # return annotation = the Pydantic output model
    ...
    return ListRepositoriesOutput(success=True, repositories=[...])
The order is not stylistic — reversing it breaks the tool. @tool must be the outer decorator and @serialize_pydantic_return the inner one. Because decorators apply bottom-up, serialize_pydantic_return wraps the raw function first; @tool then wraps the dict-coercing version. Reverse them and @tool would wrap the typed Pydantic-returning function, the dict coercion would never fire, and serialization downstream would fail.

What each decorator does

@tool(args_schema=...)
langchain_core.tools.tool
The LangChain decorator. It turns the function into a StructuredTool whose input schema is the Pydantic model you pass as args_schema. The tool is invoked with a single input dict via await tool.ainvoke({...}). args_schema is required for every ModuleX tool — it is the callable surface (see below).
@serialize_pydantic_return
modulex_integrations.serialize_pydantic_return
A ModuleX async wrapper (imported from the package root: from modulex_integrations import serialize_pydantic_return). At run time, if the function returns a Pydantic BaseModel it returns result.model_dump() — a plain dict — instead. Non-BaseModel returns pass through unchanged. Crucially, it does not change the function’s return annotation: the annotation still points at the Pydantic class, so static analysis and the backend’s output-schema derivation keep reading the model, while the runtime value becomes a JSON-serializable dict.
Returning a bare BaseModel broke the JSON boundary in the backend — a raw model is not JSON-serializable, so json.dumps() raised a TypeError. The wrapper dict-coerces the value at the tool boundary while preserving the typed annotation, which the backend reads to derive the output schema. This is exactly why the annotation must keep pointing at the model class even though the value at run time is a dict — see The output model becomes the output schema.

The args_schema is the callable surface

Each @tool takes a paired Pydantic input model via args_schema=. Its fields mirror the function parameters — same names, same defaults. Required parameters have no default; optional ones use Field(default=..., description=...).
tools/github/tools.py — input model mirrors the signature
from typing import Any

from pydantic import BaseModel, Field

class ListRepositoriesInput(BaseModel):
    auth_type: str = Field(description="Authentication type (oauth2, bearer_token)")
    auth_data: dict[str, Any] = Field(description="Authentication data containing tokens")
    visibility: str = Field(default="all", description="all, public, or private")
    affiliation: str = Field(
        default="owner,collaborator,organization_member",
        description="Comma-separated affiliations",
    )
    sort: str = Field(default="full_name", description="Sort field")
    direction: str = Field(default="asc", description="Sort direction")
    per_page: int = Field(default=30, description="Results per page")
    page: int = Field(default=1, description="Page number")
The manifest’s parameters are NOT the callable surface — the args_schema is. A manifest’s ActionDefinition.parameters may list fewer fields than the function actually accepts. For example, GitHub’s create_issue accepts and forwards assignees, and list_repositories accepts affiliation, direction, and page — all declared in the args_schema and reaching the upstream API — yet the manifest omits them. When you document or call a tool, read the args_schema, not the manifest. (Tracked as discrepancy D1.)

How a tool is invoked

Tools are not called as plain functions. The runtime invokes them through LangChain with a single input dict:
Invocation shape
result = await create_issue.ainvoke({
    "owner": "octocat",
    "repo": "hello-world",
    "title": "Track the docs rebuild",
    "body": "First issue from a ModuleX tool.",
    # auth params are injected by the runtime — see below
})
# result is a dict (serialize_pydantic_return dict-coerced the output model)
The returned value is a dict because serialize_pydantic_return coerced the output model. The auth parameters are not passed by the caller — the runtime injects them.

Auth parameter patterns

A tool never receives raw credentials from the model. The model-facing schema omits all credential fields (api_key, token, auth_token, access_token, bearer_token, auth_type, auth_data); the runtime resolves the org’s stored credential and injects the auth parameters at call time. There are two conventions for how those parameters appear on the function signature.
The first two parameters are the auth pair: auth_type: str and auth_data: dict[str, Any]. A module-level helper builds provider headers by branching on auth_type.
tools/github/tools.py — header helper
def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]:
    headers = {"Accept": "application/vnd.github+json"}
    if auth_type == "oauth2":
        access_token = auth_data.get("access_token")   # oauth2 reads access_token
        if access_token:
            headers["Authorization"] = f"Bearer {access_token}"
    elif auth_type == "bearer_token":
        token = auth_data.get("token")                 # bearer_token reads token
        if token:
            headers["Authorization"] = f"Bearer {token}"
    return headers
The two auth_type string values these integrations accept are oauth2 and bearer_token, matching the two auth_schemas the manifest declares (OAuth2AuthSchema + BearerTokenAuthSchema).
How the runtime injects auth. Before calling the tool, the runtime strips the credential fields from the model-facing schema (so the LLM never sees them), then auto-fills any signature parameter whose name matches a key in the resolved credential’s data — api_key, token, access_token, and so on land on the matching parameter; the auth_type/auth_data pair is injected for tools that declare it. The full credential-resolution path — encryption, the OAuth2 PKCE flow, the six auth schema variants, and credit gating on modulex_key resolution — is documented in Integration authentication & credentials.
Both auth conventions appear in the catalog today, but a manifest’s auth schema and the function signature must agree: declare oauth2 / bearer_token schemas with the auth_type + auth_data pair, and declare api_key / modulex_key schemas with a named api_key parameter. The custom and internal auth variants have no reference integration among GitHub, Slack, or Exa, so their signature shape is not demonstrated here — internal is a reserved variant with no shipped catalog usage.

Typed Pydantic output models

Each tool’s return annotation is a typed output model defined in outputs.py. Every output model subclasses a private base configured with model_config = ConfigDict(extra="forbid"), so an unexpected field fails fast. There are two layers of model.
tools/github/outputs.py — the two layers
from pydantic import BaseModel, ConfigDict, Field

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

# Layer 1 — nested resource model. Fields are intentionally permissive
# (X | None = None) because the upstream API omits fields.
class IssueCreated(_Base):
    number: int | None = None
    title: str | None = None
    body: str | None = None
    state: str | None = None
    url: str | None = None
    created_at: str | None = None

# Layer 2 — per-action output wrapper carrying the success flag + payload.
class CreateIssueOutput(_Base):
    success: bool          # required; no `error` field — this model raises on failure
    issue: IssueCreated
success
bool
required
Universal. Every output model carries success: bool as the one stable, cross-integration field. It is required.
error
str | None
default:"null"
Conditional. Present only on integrations whose error model returns a failure payload rather than raising — see the table below. Do not assume every tool returns an error field.
error field presence is integration-dependent. A generic “tool output” claim that every tool returns {success, error} is wrong: GitHub’s models have no error field (failures raise before a model is constructed), while Slack and Exa both carry error: str | None. The only field you can rely on across every integration is success: bool. (Tracked as discrepancy D6.)
Integrationsuccesserror fieldWhy
GitHubsuccess: bool (required)AbsentFailures raise before the model is built.
Slacksuccess: boolerror: str | None = NoneFailure path returns a success=False model.
Exasuccess: boolerror: str | None = NoneAll exceptions are caught and returned as a model.

The output model becomes the output schema

The output shape is deliberately not declared in the manifest. Neither ActionDefinition nor IntegrationManifest carries an output_schema field — a reader of the manifest alone will not find output shapes; they live in outputs.py. (Tracked as discrepancy D2.) Instead, the backend derives the output schema at startup from the @tool function’s return annotation:
1

Resolve the underlying function

The loader resolves the tool’s underlying coroutine (the function wrapped by @tool).
2

Read the typed annotation

It calls typing.get_type_hints(func) to resolve the stringified -> CreateIssueOutput return annotation. This is why serialize_pydantic_return must preserve the annotation even though it dict-coerces the value.
3

Generate JSON Schema

If the resolved return type has model_json_schema(), the loader sets the action’s output schema to return_type.model_json_schema().
The chain is the whole reason the decorator order is mandatory: the model-facing output schema comes from the annotation, while the JSON-serializable value comes from the dict coercion. @tool outer + @serialize_pydantic_return inner is what lets both be true at once.

The three error patterns

A tool function must decide what happens on an upstream failure. The three reference integrations demonstrate the three sanctioned patterns. Pick one and apply it consistently across all of an integration’s tools.
Call response.raise_for_status() after each request and only ever construct success=True models. HTTP errors propagate as exceptions; the runtime handles them. Output models have no error field.
Pattern A — raise on non-2xx
async with httpx.AsyncClient() as client:
    response = await client.post(url, headers=headers, json=payload)
    response.raise_for_status()      # non-2xx raises here
    data = response.json()
return CreateIssueOutput(success=True, issue=IssueCreated(...))
Use this when the upstream API returns proper HTTP status codes and you want failures surfaced as exceptions.
🎬 MEDIA PLACEHOLDER · MX-MEDIA-4190 · [IMAGE] [IMAGE_DESCRIPTION]: Decision diagram mapping the three tool error patterns to when each is used. [IMAGE_DETAILS]: Three labeled columns — A “raise” (GitHub), B “inline ok:false” (Slack), C “try/except” (Exa). For each, show the trigger (proper HTTP status codes / HTTP 200 with ok:false body / any failure must be non-raising), the action taken (response.raise_for_status() / check data["ok"] / wrap in try/except + pre-validate), and the resulting output-model shape (no error field / success=False + error / success=False + error). Add a footer note: “success: bool is universal; error exists only on B and C.” Light theme, 16:9, monospace for code tokens.

Worked example — GitHub create_issue end to end

This ties the whole contract together: the input model, the output model, and the @tool function, with the manifest action for context.
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.github.outputs import (
    CreateIssueOutput,
    IssueCreated,
)

class CreateIssueInput(BaseModel):
    auth_type: str = Field(description="Authentication type")
    auth_data: dict[str, Any] = Field(description="Authentication data")
    owner: str = Field(description="Repository owner")
    repo: str = Field(description="Repository name")
    title: str = Field(description="Issue title")
    body: str | None = Field(default=None, description="Issue body/description")
    labels: list[str] | None = Field(default=None, description="List of label names")
    assignees: list[str] | None = Field(default=None, description="Usernames to assign")

@tool(args_schema=CreateIssueInput)        # OUTER
@serialize_pydantic_return                  # INNER
async def create_issue(
    auth_type: str,
    auth_data: dict[str, Any],
    owner: str,
    repo: str,
    title: str,
    body: str | None = None,
    labels: list[str] | None = None,
    assignees: list[str] | None = None,
) -> CreateIssueOutput:                      # annotation read by the loader
    """Create an issue in a repository."""
    headers = _get_auth_headers(auth_type, auth_data)
    payload: dict[str, Any] = {"title": title}
    if body:
        payload["body"] = body
    if labels:
        payload["labels"] = labels
    if assignees:
        payload["assignees"] = assignees
    async with httpx.AsyncClient() as client:
        response = await client.post(
            f"https://api.github.com/repos/{owner}/{repo}/issues",
            headers=headers,
            json=payload,
        )
        response.raise_for_status()          # Pattern A
        i = response.json()
    return CreateIssueOutput(
        success=True,
        issue=IssueCreated(
            number=i.get("number"),
            title=i.get("title"),
            body=i.get("body"),
            state=i.get("state"),
            url=i.get("html_url"),            # wire field html_url -> model field url
            created_at=i.get("created_at"),
        ),
    )
The GitHub API field html_url is mapped onto the model’s url field; similar wire-to-model remapping appears across the integration (for example stargazers_count becomes stars). Map upstream field names to your model field names explicitly in the tool body.

Worked example — Slack post_message

Slack uses the inline ok:false error pattern (Pattern B) and an output model that carries both success and error. Required input fields (channel_id, text) have no default.
@tool(args_schema=PostMessageInput)
@serialize_pydantic_return
async def post_message(
    auth_type: str,
    auth_data: dict[str, Any],
    channel_id: str,
    text: str,
) -> PostMessageOutput:
    """Post a message to a Slack channel."""
    headers = _get_auth_headers(auth_type, auth_data)
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://slack.com/api/chat.postMessage",
            headers=headers,
            json={"channel": channel_id, "text": text},
        )
    data = response.json()                    # no raise_for_status — Slack returns 200
    if not data.get("ok"):
        return PostMessageOutput(success=False, error=data.get("error", "Unknown error"))
    return PostMessageOutput(success=True, ts=data.get("ts"), channel=data.get("channel"))
Slack returns HTTP 200 even for failures, so the tool checks data["ok"] rather than the status code. A failure such as {"ok": false, "error": "channel_not_found"} becomes PostMessageOutput(success=False, error="channel_not_found"). Slack’s migrated output models also flatten the legacy {"success", "action", "result": {...}} wrapper — there is no result or action key on the package output.
Exa uses the single api_key parameter auth convention and the try/except wrapper error pattern (Pattern C). Every failure becomes a model; nothing raises.
_EXA_API_BASE = "https://api.exa.ai"
_SEARCH_TIMEOUT = 30.0

@tool(args_schema=SearchInput)
@serialize_pydantic_return
async def search(query: str, api_key: str, num_results: int = 10) -> SearchOutput:
    """Run a neural search over the web with Exa."""
    if not api_key or not api_key.strip():
        return SearchOutput(success=False, error="Exa API key is empty.")
    payload = {"query": query, "numResults": num_results}
    try:
        async with httpx.AsyncClient(timeout=_SEARCH_TIMEOUT) as client:
            response = await client.post(
                f"{_EXA_API_BASE}/search",
                headers=_headers(api_key),
                json=payload,
            )
        if response.status_code != 200:
            return SearchOutput(
                success=False,
                error=f"Exa API error ({response.status_code}): {response.text}",
            )
        data = response.json()
    except httpx.TimeoutException:
        return SearchOutput(success=False, error="Request timed out.")
    except Exception as exc:
        return SearchOutput(success=False, error=f"Search failed: {exc}")
    return SearchOutput(
        success=True,
        results=[SearchResult(**r) for r in data.get("results", [])],
    )
Exa takes api_key directly rather than the auth_type/auth_data pair, and it catches every exception so the agent always receives a structured model. It also sets explicit per-endpoint timeouts (search uses 30.0s; content fetches use a longer timeout). Pricing figures in Exa’s README are approximate and live only in prose — they are not encoded in any manifest or output field, and managed-key usage is metered in ModuleX credits; see Credits & metering.

Contract checklist

Before you register an integration, confirm each tool satisfies the contract.
1

Decorator order

@tool(args_schema=...) is the outer decorator; @serialize_pydantic_return is the inner one. The function is async def.
2

args_schema mirrors the signature

The args_schema model’s fields match the function parameters (names + defaults). Required parameters have no default. Remember the args_schema — not the manifest — is the callable surface.
3

Auth convention is consistent with the manifest

Use auth_type + auth_data for oauth2 / bearer_token schemas, or a named api_key parameter for api_key / modulex_key schemas. Never accept raw credentials from the model.
4

Typed output model as the return annotation

The return annotation is a Pydantic model from outputs.py (a subclass of your extra="forbid" base). Every model has success: bool; add error: str | None only if your error pattern returns a failure model.
5

One error pattern, applied consistently

Choose Pattern A (raise), B (inline ok:false), or C (try/except) and use it across all of the integration’s tools.
6

Exported in __init__.py

manifest and TOOLS are re-exported, and every @tool object is in TOOLS.
Do not advertise per-tool pip install extras (such as [github,slack] or [all]) for your integration. The packaging extras are currently empty — installing them emits pip warnings and installs only the core package. Until the dependency-assembly step ships, integration dependencies belong in your dependencies.toml and the core install. See Installing & using integrations.

Next steps

Manifest & schema contract

The metadata side: IntegrationManifest, ActionDefinition, ParameterDef, and the six auth schema variants.

Build an integration

The full package anatomy and how an integration is discovered and registered.

Integration authentication & credentials

How the runtime resolves and injects the credentials your tool’s auth parameters receive.

Custom MCP servers

Expose tools from an external MCP server instead of authoring a Python integration.