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 atsrc/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. |
__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— oneIntegrationManifestinstance.TOOLS— a tuple of the@toolobjects (LangChainStructuredTools), one per action.
__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 anasync def wrapped by two decorators, in a fixed order.
The mandatory two-decorator stack
What each decorator does
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).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.Why serialize_pydantic_return exists
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 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
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
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.
- auth_type + auth_data (GitHub, Slack)
- api_key (Exa)
The first two parameters are the auth pair: The two
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
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.Typed Pydantic output models
Each tool’s return annotation is a typed output model defined inoutputs.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
Universal. Every output model carries
success: bool as the one stable,
cross-integration field. It is required.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.| 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. NeitherActionDefinition 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:
Resolve the underlying function
The loader resolves the tool’s underlying coroutine (the function wrapped by
@tool).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.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.- A — raise (GitHub)
- B — inline ok:false (Slack)
- C — try/except wrapper (Exa)
Call Use this when the upstream API returns proper HTTP status codes and you want
failures surfaced as exceptions.
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
🎬 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.
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.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.
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.Decorator order
@tool(args_schema=...) is the outer decorator; @serialize_pydantic_return is the
inner one. The function is async def.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.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.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.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.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.