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

# The @tool function contract

> The exhaustive @tool decorator contract for ModuleX integrations: the mandatory decorator order, the two auth-parameter conventions, typed Pydantic output models, the three error patterns, and worked GitHub, Slack, and Exa examples.

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>;
};

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](/integrations/building/manifest-schema). For the
end-to-end anatomy of an integration package, see
[Build an integration](/integrations/building/overview).

<Note>
  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](#the-args_schema-is-the-callable-surface).
</Note>

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

| File                | Role in the tool contract                                                                                                                     |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `tools.py`          | The `args_schema` input models, the auth-header helper, and the `@tool` functions.                                                            |
| `outputs.py`        | The typed Pydantic **output models** — one per action — used as each tool's return annotation.                                                |
| `manifest.py`       | Builds the single `IntegrationManifest` named `manifest`. Documented in [Manifest & schema contract](/integrations/building/manifest-schema). |
| `__init__.py`       | Re-exports `manifest` and a `TOOLS` tuple — the only two names the runtime reads.                                                             |
| `dependencies.toml` | A `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 `StructuredTool`s), one per
  action.

Nothing else in the module is part of the contract. A minimal `__init__.py`
re-exports both:

```python title="tools/github/__init__.py" theme={null}
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"]
```

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

## The @tool decorator stack

Every action is an `async def` wrapped by **two decorators, in a fixed order**.

```python title="The mandatory two-decorator stack" theme={null}
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=[...])
```

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

### What each decorator does

<ParamField path="@tool(args_schema=...)" type="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).
</ParamField>

<ParamField path="@serialize_pydantic_return" type="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.
</ParamField>

<Accordion title="Why serialize_pydantic_return exists">
  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-output-model-becomes-the-output-schema).
</Accordion>

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

```python title="tools/github/tools.py — input model mirrors the signature" theme={null}
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")
```

<Warning>
  **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.)
</Warning>

### How a tool is invoked

Tools are not called as plain functions. The runtime invokes them through LangChain
with a single input dict:

```python title="Invocation shape" theme={null}
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.

<Tabs>
  <Tab title="auth_type + auth_data (GitHub, Slack)">
    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`.

    ```python title="tools/github/tools.py — header helper" theme={null}
    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`).
  </Tab>

  <Tab title="api_key (Exa)">
    Some integrations take a single `api_key: str` as the second parameter instead of
    the auth pair. The runtime auto-fills any parameter whose **name** matches a key in
    the resolved credential, so a parameter named `api_key` receives the resolved key
    (from either an `api_key` or a `modulex_key` credential) directly.

    ```python title="tools/exa/tools.py — header helper" theme={null}
    def _headers(api_key: str) -> dict[str, str]:
        return {"x-api-key": api_key, "Content-Type": "application/json"}

    @tool(args_schema=SearchInput)
    @serialize_pydantic_return
    async def search(query: str, api_key: str, num_results: int = 10) -> SearchOutput:
        ...
    ```

    Exa's manifest declares `ApiKeyAuthSchema` + `ModulexKeyAuthSchema` for this
    convention.
  </Tab>
</Tabs>

<Note>
  **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](/integrations/authentication).
</Note>

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

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

```python title="tools/github/outputs.py — the two layers" theme={null}
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
```

<ParamField path="success" type="bool" required>
  **Universal.** Every output model carries `success: bool` as the one stable,
  cross-integration field. It is required.
</ParamField>

<ParamField path="error" type="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.
</ParamField>

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

| Integration | `success`                  | `error` field               | Why                                                |
| ----------- | -------------------------- | --------------------------- | -------------------------------------------------- |
| GitHub      | `success: bool` (required) | **Absent**                  | Failures raise before the model is built.          |
| Slack       | `success: bool`            | `error: str \| None = None` | Failure path returns a `success=False` model.      |
| Exa         | `success: bool`            | `error: str \| None = None` | All 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**:

<Steps>
  <Step title="Resolve the underlying function">
    The loader resolves the tool's underlying coroutine (the function wrapped by
    `@tool`).
  </Step>

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

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

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

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

<Tabs>
  <Tab title="A — raise (GitHub)">
    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.

    ```python title="Pattern A — raise on non-2xx" theme={null}
    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.
  </Tab>

  <Tab title="B — inline ok:false (Slack)">
    Some APIs return **HTTP 200** with an `{"ok": false, "error": "..."}` body. Read the
    JSON, check the success flag, and on the failure branch return the output model with
    `success=False` and `error` set; the success branch leaves `error` as `None`. Do
    **not** call `raise_for_status()` here — inspect the body instead.

    ```python title="Pattern B — check the body, not the status" theme={null}
    data = response.json()
    if not data.get("ok"):
        return ListChannelsOutput(
            success=False,
            error=data.get("error", "Unknown error"),
        )
    # success branch populates fields; error stays None
    return ListChannelsOutput(success=True, channels=[...])
    ```
  </Tab>

  <Tab title="C — try/except wrapper (Exa)">
    Wrap every call in `try/except` so non-2xx responses, timeouts, and unexpected
    exceptions all surface as `success=False` + `error` and **never** raise. Validate
    inputs (such as a present `api_key`) before any HTTP call, and set explicit
    per-endpoint timeouts.

    ```python title="Pattern C — catch everything, return a model" theme={null}
    if not api_key or not api_key.strip():
        return SearchOutput(success=False, error="Exa API key is empty.")   # pre-check
    try:
        async with httpx.AsyncClient(timeout=30.0) as client:
            response = await client.post(f"{_EXA_API_BASE}/search", headers=..., 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=[...])
    ```
  </Tab>
</Tabs>

<MediaEmbed id="MX-MEDIA-4190" type="image" caption={"Decision diagram mapping the three tool error patterns to when each is used."} />

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

<CodeGroup>
  ```python tools.py theme={null}
  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"),
          ),
      )
  ```

  ```python outputs.py theme={null}
  from pydantic import BaseModel, ConfigDict

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

  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

  class CreateIssueOutput(_Base):
      success: bool
      issue: IssueCreated
  ```

  ```python manifest.py (action excerpt) theme={null}
  from modulex_integrations.schema import ActionDefinition, ParameterDef

  ActionDefinition(
      name="create_issue",                     # must match ^[a-z][a-z0-9_]*$
      description="Create a new issue",
      parameters={
          "owner": ParameterDef(type="string", description="Repository owner", required=True),
          "repo":  ParameterDef(type="string", description="Repository name",  required=True),
          "title": ParameterDef(type="string", description="Issue title",      required=True),
          "body":  ParameterDef(type="string", description="Issue body"),        # required defaults to False
          "labels": ParameterDef(type="array", description="Issue labels"),
          # NOTE: `assignees` is intentionally absent here but present in the
          # args_schema and forwarded to the API — the args_schema is canonical.
      },
  )
  ```
</CodeGroup>

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

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

<CodeGroup>
  ```python tools.py theme={null}
  @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"))
  ```

  ```python outputs.py theme={null}
  class PostMessageOutput(_Base):
      success: bool
      error: str | None = None
      ts: str | None = None
      channel: str | None = None
  ```
</CodeGroup>

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

## Worked example — Exa `search`

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.

<CodeGroup>
  ```python tools.py theme={null}
  _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", [])],
      )
  ```

  ```python outputs.py theme={null}
  class SearchResult(_Base):
      title: str | None = None
      url: str | None = None
      text: str | None = None
      score: float | None = None

  class SearchOutput(_Base):
      success: bool
      error: str | None = None
      results: list[SearchResult] = Field(default_factory=list)
  ```
</CodeGroup>

<Note>
  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](/billing/credits).
</Note>

## Contract checklist

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

<Steps>
  <Step title="Decorator order">
    `@tool(args_schema=...)` is the outer decorator; `@serialize_pydantic_return` is the
    inner one. The function is `async def`.
  </Step>

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

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

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

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

  <Step title="Exported in __init__.py">
    `manifest` and `TOOLS` are re-exported, and every `@tool` object is in `TOOLS`.
  </Step>
</Steps>

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

## Next steps

<CardGroup cols={2}>
  <Card title="Manifest & schema contract" icon="file-code" href="/integrations/building/manifest-schema">
    The metadata side: `IntegrationManifest`, `ActionDefinition`, `ParameterDef`, and
    the six auth schema variants.
  </Card>

  <Card title="Build an integration" icon="puzzle" href="/integrations/building/overview">
    The full package anatomy and how an integration is discovered and registered.
  </Card>

  <Card title="Integration authentication & credentials" icon="key" href="/integrations/authentication">
    How the runtime resolves and injects the credentials your tool's auth parameters
    receive.
  </Card>

  <Card title="Custom MCP servers" icon="server" href="/integrations/building/custom-mcp">
    Expose tools from an external MCP server instead of authoring a Python integration.
  </Card>
</CardGroup>
