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.

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

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).
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
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
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
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
bool
required
Universal. Every output model carries success: bool as the one stable, cross-integration field. It is required.
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.)

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
Use this when the upstream API returns proper HTTP status codes and you want failures surfaced as exceptions.

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