diff --git a/.github/workflows/auto-integrate.yml b/.github/workflows/auto-integrate.yml
index 1e2a435..cccaa9a 100644
--- a/.github/workflows/auto-integrate.yml
+++ b/.github/workflows/auto-integrate.yml
@@ -26,14 +26,23 @@ name: auto-integrate
# GitHub Actions injection-safety guidelines.
on:
- schedule:
- # 5-minute cadence (GitHub Actions minimum). `concurrency` block
- # below ensures only one run executes at a time; overlapping
- # cron triggers queue rather than parallelize. Combined with
- # the self-dispatch step at the end of the workflow, this gives
- # near-continuous processing when the producer queue has work,
- # and 5-minute polling when it doesn't.
- - cron: '*/5 * * * *'
+ # ─── DEACTIVATED 2026-05-30 ──────────────────────────────────────────────
+ # The auto-integrate cron is intentionally disabled. The workflow files are
+ # kept so the pipeline can be re-enabled later. To reactivate:
+ # 1. Uncomment the `schedule:` block below.
+ # 2. Restore the `if:` condition on the "Self-dispatch next run" step
+ # (its original value is preserved in a comment there).
+ # 3. Re-enable on GitHub: gh workflow enable auto-integrate.yml
+ # `workflow_dispatch` stays active so the pipeline can still be run manually
+ # on demand without restarting the automatic chain.
+ # schedule:
+ # # 5-minute cadence (GitHub Actions minimum). `concurrency` block
+ # # below ensures only one run executes at a time; overlapping
+ # # cron triggers queue rather than parallelize. Combined with
+ # # the self-dispatch step at the end of the workflow, this gives
+ # # near-continuous processing when the producer queue has work,
+ # # and 5-minute polling when it doesn't.
+ # - cron: '*/5 * * * *'
workflow_dispatch:
inputs:
provider:
@@ -406,7 +415,13 @@ jobs:
# Concurrency group (`auto-integrate`) ensures self-dispatched runs
# never overlap with cron-triggered runs — they queue serially.
- name: Self-dispatch next run (chain on success)
- if: steps.run.outputs.skipped != 'true' && steps.run.outputs.failed != 'true' && steps.run.outputs.tool_name != '' && github.event.inputs.dry_run != 'true'
+ # DEACTIVATED 2026-05-30: the self-dispatch chain is disabled alongside
+ # the cron (see the `on:` block at the top of this file). This prevents
+ # a manual `workflow_dispatch` run from restarting the automatic chain.
+ # To reactivate, restore the original condition preserved on the next
+ # line and uncomment the `schedule:` block above.
+ # Original: steps.run.outputs.skipped != 'true' && steps.run.outputs.failed != 'true' && steps.run.outputs.tool_name != '' && github.event.inputs.dry_run != 'true'
+ if: false
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TOOL_NAME: ${{ steps.run.outputs.tool_name }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 30db015..8a8aaa2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,89 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
### Added
+- `revolt` integration — 3 actions, auth: bearer_token. Revolt open-source
+ chat platform — group management and friend requests (create_group,
+ add_group_member, send_friend_request). Producer-staged by
+ integration-drafts; consumer-side audit applied 5 patches before merge.
+
+- `woocommerce` integration — 17 actions, auth: custom. WooCommerce REST API
+ integration for managing orders, products, customers, and refunds on
+ self-hosted WooCommerce stores (create_order, get_order, list_orders,
+ delete_order, update_order_status, create_product, update_product,
+ get_product, list_products, search_customers, get_customer,
+ create_customer, add_order_note, get_order_note, list_order_notes,
+ create_refund, list_payment_method_options). Producer-staged by
+ integration-drafts; consumer-side audit applied 2 patches before merge.
+
+- `coinmarketcap` integration — 4 actions, auth: api_key. Cryptocurrency
+ market data, quotes, and metadata from the CoinMarketCap API
+ (get_cryptocurrency_metadata, id_map, latest_listings, latest_quotes).
+ Producer-staged by integration-drafts; consumer-side audit applied
+ 2 patches before merge.
+
+- `cogmento` integration — 4 actions, auth: oauth2. CRM platform for
+ managing contacts, deals, and tasks via the Cogmento API
+ (create_contact, create_deal, create_task, list_user_ids_options).
+ Producer-staged by integration-drafts; consumer-side audit applied
+ 3 patches before merge.
+
+- `fellow` integration — 3 actions, auth: api_key. Meeting productivity
+ platform for notes, action items, and meeting management via the Fellow API
+ (archive_action_item, complete_action_item, get_note_by_id). Producer-staged
+ by integration-drafts; consumer-side audit applied 1 patch before merge.
+
+- `canvas` integration — 5 actions, auth: custom. Canvas LMS integration
+ for course, assignment, and user management via the Canvas REST API
+ (list_accounts, list_courses, list_assignments, search_course_content,
+ update_assignment). Producer-staged by integration-drafts; consumer-side
+ audit applied 2 patches before merge.
+
+- `motion` integration — 6 actions, auth: api_key. AI-powered task and
+ project management platform for automatic scheduling via the Motion API
+ (create_task, delete_task, get_schedules, get_task, move_workspace,
+ update_task). Producer-staged by integration-drafts; consumer-side audit
+ applied 1 patch before merge.
+
+- `livestorm` integration — 7 actions, auth: oauth2. Video engagement
+ platform for webinars and virtual events via the Livestorm REST API
+ (create_event, get_event, list_attendees_from_event, list_events,
+ list_sessions, register_someone_for_session, update_event).
+ Producer-staged by integration-drafts; consumer-side audit applied
+ 12 patches before merge.
+
+- `heygen` integration — 5 actions, auth: api_key. AI video generation
+ platform for creating talking avatar videos via the HeyGen API
+ (create_talking_photo, create_video_from_template,
+ list_custom_events_options, list_voice_id_options, retrieve_video_link).
+ Producer-staged by integration-drafts; consumer-side audit applied
+ 1 patch before merge.
+
+- `yelp` integration — 4 actions, auth: api_key. Search businesses, read
+ reviews, and get business details via the Yelp Fusion API
+ (search_businesses, get_business_details, list_business_reviews,
+ search_businesses_by_phone_number). Producer-staged by
+ integration-drafts; consumer-side audit applied 1 patch before merge.
+
+- `square` integration — 6 actions, auth: oauth2. Payment processing,
+ commerce, and business management platform via the Square Connect v2 API
+ (create_customer, create_invoice, create_order, list_event_types_options,
+ list_location_options, send_invoice). Producer-staged by
+ integration-drafts; consumer-side audit applied 4 patches before merge.
+
+- `hunter` integration — 13 actions, auth: api_key. Professional email
+ finding and verification via the Hunter.io API (account_information,
+ combined_enrichment, create_lead, delete_lead, domain_search,
+ email_count, email_finder, email_verifier, get_lead, get_leads_list,
+ list_leads, list_leads_lists, update_lead). Producer-staged by
+ integration-drafts; consumer-side audit applied 1 patch before merge.
+
+- `gong` integration — 5 actions, auth: oauth2. Revenue intelligence
+ platform for recording, transcribing, and analyzing sales conversations
+ via the Gong REST API (add_new_call, get_extensive_data, list_calls,
+ list_workspace_id_options, retrieve_transcripts_of_calls).
+ Producer-staged by integration-drafts; consumer-side audit applied
+ 3 patches before merge.
+
- `figma` integration — 3 actions, auth: oauth2. Design collaboration
platform for creating, sharing, and commenting on design files via the
Figma REST API (list_comments, delete_comment, post_a_comment).
diff --git a/pyproject.toml b/pyproject.toml
index c1c85c8..20c48d7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -73,6 +73,7 @@ slack = "modulex_integrations.tools.slack"
etsy = "modulex_integrations.tools.etsy"
exa = "modulex_integrations.tools.exa"
fal_ai = "modulex_integrations.tools.fal_ai"
+fellow = "modulex_integrations.tools.fellow"
figma = "modulex_integrations.tools.figma"
tavily = "modulex_integrations.tools.tavily"
insightly = "modulex_integrations.tools.insightly"
@@ -89,9 +90,11 @@ crunchbase = "modulex_integrations.tools.crunchbase"
dropbox = "modulex_integrations.tools.dropbox"
docusign = "modulex_integrations.tools.docusign"
hackernews = "modulex_integrations.tools.hackernews"
+heygen = "modulex_integrations.tools.heygen"
help_scout = "modulex_integrations.tools.help_scout"
heroku = "modulex_integrations.tools.heroku"
hootsuite = "modulex_integrations.tools.hootsuite"
+hunter = "modulex_integrations.tools.hunter"
lemon_squeezy = "modulex_integrations.tools.lemon_squeezy"
short_io = "modulex_integrations.tools.short_io"
nasdaq = "modulex_integrations.tools.nasdaq"
@@ -103,9 +106,11 @@ jira = "modulex_integrations.tools.jira"
cal_com = "modulex_integrations.tools.cal_com"
calendly = "modulex_integrations.tools.calendly"
canva = "modulex_integrations.tools.canva"
+canvas = "modulex_integrations.tools.canvas"
instructure_canvas = "modulex_integrations.tools.instructure_canvas"
linear = "modulex_integrations.tools.linear"
linkedin = "modulex_integrations.tools.linkedin"
+livestorm = "modulex_integrations.tools.livestorm"
luma = "modulex_integrations.tools.luma"
ahrefs = "modulex_integrations.tools.ahrefs"
airtable = "modulex_integrations.tools.airtable"
@@ -121,9 +126,11 @@ intercom = "modulex_integrations.tools.intercom"
scrape_do = "modulex_integrations.tools.scrape_do"
apollo_io = "modulex_integrations.tools.apollo_io"
cloudflare = "modulex_integrations.tools.cloudflare"
+cogmento = "modulex_integrations.tools.cogmento"
segment = "modulex_integrations.tools.segment"
semrush = "modulex_integrations.tools.semrush"
gmail = "modulex_integrations.tools.gmail"
+gong = "modulex_integrations.tools.gong"
godaddy = "modulex_integrations.tools.godaddy"
google_cloud = "modulex_integrations.tools.google_cloud"
google_contacts = "modulex_integrations.tools.google_contacts"
@@ -136,16 +143,19 @@ google_docs = "modulex_integrations.tools.google_docs"
sendgrid = "modulex_integrations.tools.sendgrid"
sentry = "modulex_integrations.tools.sentry"
coinbase = "modulex_integrations.tools.coinbase"
+coinmarketcap = "modulex_integrations.tools.coinmarketcap"
databricks = "modulex_integrations.tools.databricks"
datadog = "modulex_integrations.tools.datadog"
postgresql = "modulex_integrations.tools.postgresql"
mysql = "modulex_integrations.tools.mysql"
snowflake = "modulex_integrations.tools.snowflake"
+square = "modulex_integrations.tools.square"
supabase = "modulex_integrations.tools.supabase"
hubspot = "modulex_integrations.tools.hubspot"
notion = "modulex_integrations.tools.notion"
elevenlabs = "modulex_integrations.tools.elevenlabs"
reflect = "modulex_integrations.tools.reflect"
+revolt = "modulex_integrations.tools.revolt"
salesforce = "modulex_integrations.tools.salesforce"
clickup = "modulex_integrations.tools.clickup"
google_drive = "modulex_integrations.tools.google_drive"
@@ -176,6 +186,7 @@ microsoft_teams = "modulex_integrations.tools.microsoft_teams"
mintlify = "modulex_integrations.tools.mintlify"
mixpanel = "modulex_integrations.tools.mixpanel"
monday = "modulex_integrations.tools.monday"
+motion = "modulex_integrations.tools.motion"
postgrid = "modulex_integrations.tools.postgrid"
posthog = "modulex_integrations.tools.posthog"
postman = "modulex_integrations.tools.postman"
@@ -184,6 +195,8 @@ okta = "modulex_integrations.tools.okta"
pagerduty = "modulex_integrations.tools.pagerduty"
shopify = "modulex_integrations.tools.shopify"
shopify_partner = "modulex_integrations.tools.shopify_partner"
+woocommerce = "modulex_integrations.tools.woocommerce"
+yelp = "modulex_integrations.tools.yelp"
zoom = "modulex_integrations.tools.zoom"
[tool.hatch.version]
@@ -578,10 +591,57 @@ select = ["E", "F", "I", "N", "W", "B", "C4", "UP", "RUF"]
# ParameterDef / Field kwargs that cannot be wrapped.
"src/modulex_integrations/tools/instructure_canvas/manifest.py" = ["E501"]
"src/modulex_integrations/tools/instructure_canvas/tools.py" = ["E501"]
+# gong manifest and tools have long description string literals in
+# ParameterDef / Field kwargs and credential guard lines that cannot be wrapped.
+"src/modulex_integrations/tools/gong/manifest.py" = ["E501"]
+"src/modulex_integrations/tools/gong/tools.py" = ["E501"]
# postgrid manifest and tools have long description string literals in
# ParameterDef / Field kwargs that cannot be wrapped.
"src/modulex_integrations/tools/postgrid/manifest.py" = ["E501"]
"src/modulex_integrations/tools/postgrid/tools.py" = ["E501"]
+# hunter manifest, tools, and tests have long description string literals in
+# ParameterDef / Field kwargs that cannot be wrapped.
+"src/modulex_integrations/tools/hunter/manifest.py" = ["E501"]
+"src/modulex_integrations/tools/hunter/tools.py" = ["E501"]
+"src/modulex_integrations/tools/hunter/tests/test_hunter.py" = ["E501"]
+# square manifest, tools, and tests have long description string literals in
+# ParameterDef / Field kwargs that cannot be wrapped.
+"src/modulex_integrations/tools/square/manifest.py" = ["E501"]
+"src/modulex_integrations/tools/square/tools.py" = ["E501"]
+"src/modulex_integrations/tools/square/tests/test_square.py" = ["E501"]
+# heygen manifest and tools have long description string literals in
+# ParameterDef / Field kwargs that cannot be wrapped.
+"src/modulex_integrations/tools/heygen/manifest.py" = ["E501"]
+"src/modulex_integrations/tools/heygen/tools.py" = ["E501"]
+# livestorm manifest and tools have long description string literals in
+# ParameterDef / Field kwargs and credential guard lines that cannot be wrapped.
+"src/modulex_integrations/tools/livestorm/manifest.py" = ["E501"]
+"src/modulex_integrations/tools/livestorm/tools.py" = ["E501"]
+# yelp manifest and tools have long description string literals in
+# ParameterDef / Field kwargs that cannot be wrapped.
+"src/modulex_integrations/tools/yelp/manifest.py" = ["E501"]
+"src/modulex_integrations/tools/yelp/tools.py" = ["E501"]
+# motion manifest, tools, and tests have long description string literals in
+# ParameterDef / Field kwargs and credential guard lines that cannot be wrapped.
+"src/modulex_integrations/tools/motion/manifest.py" = ["E501"]
+"src/modulex_integrations/tools/motion/tools.py" = ["E501"]
+"src/modulex_integrations/tools/motion/tests/test_motion.py" = ["E501"]
+# canvas manifest and tools have long description string literals in
+# ParameterDef / Field kwargs that cannot be wrapped.
+"src/modulex_integrations/tools/canvas/manifest.py" = ["E501"]
+"src/modulex_integrations/tools/canvas/tools.py" = ["E501"]
+# cogmento tools have long description string literals in Field kwargs
+# and credential guard lines that cannot be wrapped.
+"src/modulex_integrations/tools/cogmento/tools.py" = ["E501"]
+# coinmarketcap manifest and tools have long description string literals in
+# ParameterDef / Field kwargs that cannot be wrapped.
+"src/modulex_integrations/tools/coinmarketcap/manifest.py" = ["E501"]
+"src/modulex_integrations/tools/coinmarketcap/tools.py" = ["E501"]
+# woocommerce manifest, tools, and tests have long description string literals in
+# ParameterDef / Field kwargs that cannot be wrapped.
+"src/modulex_integrations/tools/woocommerce/manifest.py" = ["E501"]
+"src/modulex_integrations/tools/woocommerce/tools.py" = ["E501"]
+"src/modulex_integrations/tools/woocommerce/tests/test_woocommerce.py" = ["E501"]
[tool.mypy]
python_version = "3.12"
diff --git a/src/modulex_integrations/tools/canvas/README.md b/src/modulex_integrations/tools/canvas/README.md
new file mode 100644
index 0000000..d940fad
--- /dev/null
+++ b/src/modulex_integrations/tools/canvas/README.md
@@ -0,0 +1,35 @@
+# Canvas LMS
+
+Learning management system integration for course, assignment, and user management via the Canvas REST API (`https://{your-domain}/api/v1`).
+
+## Authentication
+
+### Canvas OAuth Token + Domain
+
+Canvas LMS is self-hosted (each institution runs its own instance), so both your instance domain and an access token are required.
+
+- **Canvas Domain**: Your Canvas instance hostname (e.g. `myschool.instructure.com`). Required env var: `CANVAS_DOMAIN`.
+- **Access Token**: Generate from Account > Settings > Approved Integrations in your Canvas instance. Required env var: `CANVAS_ACCESS_TOKEN` (format: `7~xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`).
+- Guide: [Managing API Access Tokens](https://community.canvaslms.com/t5/Admin-Guide/How-do-I-manage-API-access-tokens-as-an-admin/ta-p/89)
+
+## Tools
+
+| name | description | required params |
+| --- | --- | --- |
+| `list_accounts` | List Canvas accounts accessible to the authenticated user. | _(none)_ |
+| `list_assignments` | Retrieve a list of assignments for a user in a specific course. | `user_id`, `course_id` |
+| `list_courses` | List all courses associated with a given user. | `user_id` |
+| `search_course_content` | Search for content in a course using Canvas smart search. | `course_id`, `query` |
+| `update_assignment` | Update an existing assignment in a course. | `course_id`, `assignment_id` |
+
+Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fills in from the resolved credential (token-style injection).
+
+## Limits & Quotas
+
+- **Rate limits**: Canvas enforces per-user rate limits (typically 700 requests per 10 minutes for the default configuration, varies by institution).
+- **Pagination**: List endpoints may return paginated results; current implementation fetches the first page.
+- **Error model**: Non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising.
+
+## Maintainer
+
+ModuleX core team.
diff --git a/src/modulex_integrations/tools/canvas/__init__.py b/src/modulex_integrations/tools/canvas/__init__.py
new file mode 100644
index 0000000..07d3602
--- /dev/null
+++ b/src/modulex_integrations/tools/canvas/__init__.py
@@ -0,0 +1,27 @@
+"""Canvas LMS integration — discovered via the ``modulex.tools`` entry point."""
+from modulex_integrations.tools.canvas.manifest import manifest
+from modulex_integrations.tools.canvas.tools import (
+ list_accounts,
+ list_assignments,
+ list_courses,
+ search_course_content,
+ update_assignment,
+)
+
+TOOLS = (
+ list_accounts,
+ list_assignments,
+ list_courses,
+ search_course_content,
+ update_assignment,
+)
+
+__all__ = [
+ "TOOLS",
+ "list_accounts",
+ "list_assignments",
+ "list_courses",
+ "manifest",
+ "search_course_content",
+ "update_assignment",
+]
diff --git a/src/modulex_integrations/tools/canvas/dependencies.toml b/src/modulex_integrations/tools/canvas/dependencies.toml
new file mode 100644
index 0000000..bd62e5e
--- /dev/null
+++ b/src/modulex_integrations/tools/canvas/dependencies.toml
@@ -0,0 +1,3 @@
+# Runtime dependencies for the canvas integration.
+# CI assembles this into the root pyproject's [project.optional-dependencies].
+dependencies = []
diff --git a/src/modulex_integrations/tools/canvas/manifest.py b/src/modulex_integrations/tools/canvas/manifest.py
new file mode 100644
index 0000000..257d424
--- /dev/null
+++ b/src/modulex_integrations/tools/canvas/manifest.py
@@ -0,0 +1,159 @@
+"""Canvas LMS integration manifest."""
+from __future__ import annotations
+
+from modulex_integrations.schema import (
+ ActionDefinition,
+ CustomAuthSchema,
+ EnvVar,
+ IntegrationManifest,
+ ParameterDef,
+)
+
+__all__ = ["manifest"]
+
+
+manifest = IntegrationManifest(
+ name="canvas",
+ display_name="Canvas LMS",
+ description="Learning management system for course, assignment, and user management via the Canvas REST API.",
+ version="1.0.0",
+ author="ModuleX",
+ logo="modulex:instructure_canvas",
+ app_url="https://www.instructure.com/canvas",
+ categories=["Education", "Learning Management"],
+ actions=[
+ ActionDefinition(
+ name="list_accounts",
+ description="List Canvas accounts accessible to the authenticated user.",
+ parameters={},
+ ),
+ ActionDefinition(
+ name="list_assignments",
+ description="Retrieve a list of assignments for a user in a specific course.",
+ parameters={
+ "user_id": ParameterDef(
+ type="string",
+ description="The ID of the user whose assignments to list.",
+ required=True,
+ ),
+ "course_id": ParameterDef(
+ type="string",
+ description="The ID of the course to list assignments from.",
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="list_courses",
+ description="List all courses associated with a given user.",
+ parameters={
+ "user_id": ParameterDef(
+ type="string",
+ description="The ID of the user whose courses to list.",
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="search_course_content",
+ description="Search for content in a course using Canvas smart search.",
+ parameters={
+ "course_id": ParameterDef(
+ type="string",
+ description="The ID of the course to search within.",
+ required=True,
+ ),
+ "query": ParameterDef(
+ type="string",
+ description="The search query string.",
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="update_assignment",
+ description="Update an existing assignment in a course.",
+ parameters={
+ "course_id": ParameterDef(
+ type="string",
+ description="The ID of the course containing the assignment.",
+ required=True,
+ ),
+ "assignment_id": ParameterDef(
+ type="string",
+ description="The ID of the assignment to update.",
+ required=True,
+ ),
+ "name": ParameterDef(
+ type="string",
+ description="The new name of the assignment.",
+ ),
+ "description": ParameterDef(
+ type="string",
+ description="The new description of the assignment (supports HTML).",
+ ),
+ "submission_type": ParameterDef(
+ type="string",
+ description="Submission type: online_quiz, none, on_paper, discussion_topic, external_tool, online_upload, online_text_entry, online_url, media_recording, student_annotation.",
+ ),
+ "notify_of_update": ParameterDef(
+ type="boolean",
+ description="Whether to notify students of the update.",
+ ),
+ "points_possible": ParameterDef(
+ type="integer",
+ description="Maximum points possible on the assignment.",
+ ),
+ "grading_type": ParameterDef(
+ type="string",
+ description="Grading strategy: pass_fail, percent, letter_grade, gpa_scale, points, not_graded.",
+ ),
+ "due_at": ParameterDef(
+ type="string",
+ description="Due date/time in ISO 8601 format (e.g. 2014-10-21T18:48:00Z).",
+ ),
+ "omit_from_final_grade": ParameterDef(
+ type="boolean",
+ description="Whether to omit this assignment from the student's final grade.",
+ ),
+ "allowed_attempts": ParameterDef(
+ type="integer",
+ description="Number of submission attempts allowed (-1 for unlimited).",
+ ),
+ },
+ ),
+ ],
+ auth_schemas=[
+ CustomAuthSchema(
+ display_name="Canvas OAuth Token + Domain",
+ description=(
+ "Authenticate using a Canvas access token and your instance domain. "
+ "Canvas LMS is self-hosted, so both the domain and token are required."
+ ),
+ setup_instructions=[
+ "Log into your Canvas instance.",
+ "Go to Account > Settings > Approved Integrations (or generate a new access token).",
+ "Copy your access token and note your Canvas domain (e.g. myschool.instructure.com).",
+ ],
+ setup_environment_variables=[
+ EnvVar(
+ name="CANVAS_DOMAIN",
+ display_name="Canvas Domain",
+ description="Your Canvas instance domain (e.g. myschool.instructure.com)",
+ required=True,
+ sensitive=False,
+ sample_format="myschool.instructure.com",
+ ),
+ EnvVar(
+ name="CANVAS_ACCESS_TOKEN",
+ display_name="Access Token",
+ description="Your Canvas API access token",
+ required=True,
+ sensitive=True,
+ sample_format="7~xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ about_url="https://community.canvaslms.com/t5/Admin-Guide/How-do-I-manage-API-access-tokens-as-an-admin/ta-p/89",
+ ),
+ ],
+ ),
+ ],
+)
diff --git a/src/modulex_integrations/tools/canvas/outputs.py b/src/modulex_integrations/tools/canvas/outputs.py
new file mode 100644
index 0000000..63e993e
--- /dev/null
+++ b/src/modulex_integrations/tools/canvas/outputs.py
@@ -0,0 +1,97 @@
+"""Pydantic response models for the canvas integration's @tool functions."""
+from __future__ import annotations
+
+from pydantic import BaseModel, ConfigDict, Field
+
+__all__ = [
+ "AccountOption",
+ "AssignmentSummary",
+ "CourseSummary",
+ "ListAccountsOutput",
+ "ListAssignmentsOutput",
+ "ListCoursesOutput",
+ "SearchCourseContentOutput",
+ "SearchResultItem",
+ "UpdateAssignmentOutput",
+]
+
+
+class _Base(BaseModel):
+ """Shared config for every output model in this integration."""
+
+ model_config = ConfigDict(extra="forbid")
+
+
+class AccountOption(_Base):
+ """A Canvas account option."""
+
+ id: int | None = None
+ name: str | None = None
+
+
+class AssignmentSummary(_Base):
+ """A Canvas assignment."""
+
+ id: int | None = None
+ name: str | None = None
+ description: str | None = None
+ due_at: str | None = None
+ points_possible: float | None = None
+ grading_type: str | None = None
+ submission_types: list[str] = Field(default_factory=list)
+ course_id: int | None = None
+ allowed_attempts: int | None = None
+ omit_from_final_grade: bool | None = None
+
+
+class CourseSummary(_Base):
+ """A Canvas course."""
+
+ id: int | None = None
+ name: str | None = None
+ course_code: str | None = None
+ workflow_state: str | None = None
+ enrollment_term_id: int | None = None
+
+
+class SearchResultItem(_Base):
+ """A search result from Canvas smart search."""
+
+ content_id: int | None = None
+ content_type: str | None = None
+ title: str | None = None
+ body: str | None = None
+ html_url: str | None = None
+ distance: float | None = None
+ readable_type: str | None = None
+ relevance: float | None = None
+
+
+class ListAccountsOutput(_Base):
+ success: bool
+ error: str | None = None
+ accounts: list[AccountOption] = Field(default_factory=list)
+
+
+class ListAssignmentsOutput(_Base):
+ success: bool
+ error: str | None = None
+ assignments: list[AssignmentSummary] = Field(default_factory=list)
+
+
+class ListCoursesOutput(_Base):
+ success: bool
+ error: str | None = None
+ courses: list[CourseSummary] = Field(default_factory=list)
+
+
+class SearchCourseContentOutput(_Base):
+ success: bool
+ error: str | None = None
+ results: list[SearchResultItem] = Field(default_factory=list)
+
+
+class UpdateAssignmentOutput(_Base):
+ success: bool
+ error: str | None = None
+ assignment: AssignmentSummary | None = None
diff --git a/src/modulex_integrations/tools/canvas/tests/__init__.py b/src/modulex_integrations/tools/canvas/tests/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/modulex_integrations/tools/canvas/tests/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/modulex_integrations/tools/canvas/tests/test_canvas.py b/src/modulex_integrations/tools/canvas/tests/test_canvas.py
new file mode 100644
index 0000000..b35353d
--- /dev/null
+++ b/src/modulex_integrations/tools/canvas/tests/test_canvas.py
@@ -0,0 +1,211 @@
+"""Happy-path tests for every canvas @tool, plus a manifest sanity check."""
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from modulex_integrations.tools.canvas import (
+ TOOLS,
+ list_accounts,
+ list_assignments,
+ list_courses,
+ manifest,
+ search_course_content,
+ update_assignment,
+)
+from modulex_integrations.tools.canvas.outputs import (
+ ListAccountsOutput,
+ ListAssignmentsOutput,
+ ListCoursesOutput,
+ SearchCourseContentOutput,
+ UpdateAssignmentOutput,
+)
+
+API = "https://myschool.instructure.com/api/v1"
+
+_AUTH: dict[str, Any] = {
+ "auth_type": "custom",
+ "auth_data": {
+ "domain": "myschool.instructure.com",
+ "access_token": "fake_token",
+ },
+}
+
+
+def _args(**extra: Any) -> dict[str, Any]:
+ """Build a ``.ainvoke()`` input dict: auth + per-test extras."""
+ return dict(_AUTH, **extra)
+
+
+# --- Manifest sanity --------------------------------------------------------
+
+
+class TestManifest:
+ def test_manifest_exposes_5_actions(self) -> None:
+ assert len(manifest.actions) == 5
+
+ def test_manifest_actions_match_tools_tuple(self) -> None:
+ assert {a.name for a in manifest.actions} == {t.name for t in TOOLS}
+
+ def test_manifest_has_custom_auth(self) -> None:
+ assert {a.auth_type for a in manifest.auth_schemas} == {"custom"}
+
+
+# --- Per-action happy-path tests -------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_list_accounts(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/accounts",
+ json=[
+ # TODO: fill in a representative response shape from the Canvas API docs
+ {"id": 1, "name": "Default Account"},
+ ],
+ )
+
+ result_dict = await list_accounts.ainvoke(_args())
+
+ assert isinstance(result_dict, dict)
+ result = ListAccountsOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.accounts) == 1
+ assert result.accounts[0].id == 1
+
+
+@pytest.mark.asyncio
+async def test_list_assignments(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/users/42/courses/101/assignments",
+ json=[
+ # TODO: fill in a representative response shape from the Canvas API docs
+ {
+ "id": 1,
+ "name": "Homework 1",
+ "due_at": "2024-10-21T18:48:00Z",
+ "points_possible": 100,
+ "grading_type": "points",
+ "submission_types": ["online_upload"],
+ "course_id": 101,
+ },
+ ],
+ )
+
+ result_dict = await list_assignments.ainvoke(_args(user_id="42", course_id="101"))
+
+ assert isinstance(result_dict, dict)
+ result = ListAssignmentsOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.assignments) == 1
+ assert result.assignments[0].name == "Homework 1"
+
+
+@pytest.mark.asyncio
+async def test_list_courses(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/users/42/courses",
+ json=[
+ # TODO: fill in a representative response shape from the Canvas API docs
+ {
+ "id": 101,
+ "name": "Introduction to AI",
+ "course_code": "CS101",
+ "workflow_state": "available",
+ "enrollment_term_id": 1,
+ },
+ ],
+ )
+
+ result_dict = await list_courses.ainvoke(_args(user_id="42"))
+
+ assert isinstance(result_dict, dict)
+ result = ListCoursesOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.courses) == 1
+ assert result.courses[0].name == "Introduction to AI"
+
+
+@pytest.mark.asyncio
+async def test_search_course_content(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/courses/101/smartsearch?q=machine+learning",
+ json=[
+ # TODO: fill in a representative response shape from the Canvas API docs
+ {
+ "content_id": 5,
+ "content_type": "WikiPage",
+ "title": "Machine Learning Basics",
+ "body": "An introduction to ML...",
+ "html_url": "https://myschool.instructure.com/courses/101/pages/ml-basics",
+ "relevance": 0.95,
+ },
+ ],
+ )
+
+ result_dict = await search_course_content.ainvoke(
+ _args(course_id="101", query="machine learning")
+ )
+
+ assert isinstance(result_dict, dict)
+ result = SearchCourseContentOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.results) == 1
+ assert result.results[0].title == "Machine Learning Basics"
+
+
+@pytest.mark.asyncio
+async def test_update_assignment(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="PUT",
+ url=f"{API}/courses/101/assignments/1",
+ json={
+ # TODO: fill in a representative response shape from the Canvas API docs
+ "id": 1,
+ "name": "Updated Homework",
+ "description": "New description",
+ "due_at": "2024-11-01T23:59:00Z",
+ "points_possible": 150,
+ "grading_type": "points",
+ "submission_types": ["online_upload"],
+ "course_id": 101,
+ "allowed_attempts": 3,
+ "omit_from_final_grade": False,
+ },
+ )
+
+ result_dict = await update_assignment.ainvoke(
+ _args(
+ course_id="101",
+ assignment_id="1",
+ name="Updated Homework",
+ points_possible=150,
+ )
+ )
+
+ assert isinstance(result_dict, dict)
+ result = UpdateAssignmentOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.assignment is not None
+ assert result.assignment.name == "Updated Homework"
+ assert result.assignment.points_possible == 150
+
+
+# --- Failure-path tests ----------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_list_accounts_empty_credentials() -> None:
+ """Empty credentials should return success=False without hitting the wire."""
+ result_dict = await list_accounts.ainvoke(
+ {"auth_type": "custom", "auth_data": {"domain": "", "access_token": ""}}
+ )
+
+ assert isinstance(result_dict, dict)
+ result = ListAccountsOutput.model_validate(result_dict)
+ assert result.success is False
+ assert result.error is not None
diff --git a/src/modulex_integrations/tools/canvas/tools.py b/src/modulex_integrations/tools/canvas/tools.py
new file mode 100644
index 0000000..da57dcb
--- /dev/null
+++ b/src/modulex_integrations/tools/canvas/tools.py
@@ -0,0 +1,366 @@
+"""Canvas LMS LangChain @tool functions."""
+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.canvas.outputs import (
+ AccountOption,
+ AssignmentSummary,
+ CourseSummary,
+ ListAccountsOutput,
+ ListAssignmentsOutput,
+ ListCoursesOutput,
+ SearchCourseContentOutput,
+ SearchResultItem,
+ UpdateAssignmentOutput,
+)
+
+__all__ = [
+ "list_accounts",
+ "list_assignments",
+ "list_courses",
+ "search_course_content",
+ "update_assignment",
+]
+
+_TIMEOUT = 30.0
+
+
+def _base_url(auth_data: dict[str, Any]) -> str:
+ domain = auth_data.get("domain", "").strip().rstrip("/")
+ if not domain:
+ return ""
+ if not domain.startswith("http"):
+ domain = f"https://{domain}"
+ return f"{domain}/api/v1"
+
+
+def _headers(auth_data: dict[str, Any]) -> dict[str, str]:
+ token = auth_data.get("access_token", "")
+ return {
+ "Authorization": f"Bearer {token}",
+ "Accept": "application/json",
+ }
+
+
+def _validate_auth(auth_data: dict[str, Any]) -> str | None:
+ domain = auth_data.get("domain", "")
+ token = auth_data.get("access_token", "")
+ if not domain or not str(domain).strip():
+ return "Canvas domain is missing. Please configure your Canvas instance domain."
+ if not token or not str(token).strip():
+ return "Access token is missing. Please configure a valid Canvas access token."
+ return None
+
+
+# --- Input schemas --------------------------------------------------------
+
+
+class ListAccountsInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+
+
+class ListAssignmentsInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ user_id: str = Field(description="The ID of the user whose assignments to list.")
+ course_id: str = Field(description="The ID of the course to list assignments from.")
+
+
+class ListCoursesInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ user_id: str = Field(description="The ID of the user whose courses to list.")
+
+
+class SearchCourseContentInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ course_id: str = Field(description="The ID of the course to search within.")
+ query: str = Field(description="The search query string.")
+
+
+class UpdateAssignmentInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ course_id: str = Field(description="The ID of the course containing the assignment.")
+ assignment_id: str = Field(description="The ID of the assignment to update.")
+ name: str | None = Field(default=None, description="The new name of the assignment.")
+ description: str | None = Field(default=None, description="The new description of the assignment (supports HTML).")
+ submission_type: str | None = Field(default=None, description="Submission type: online_quiz, none, on_paper, discussion_topic, external_tool, online_upload, online_text_entry, online_url, media_recording, student_annotation.")
+ notify_of_update: bool | None = Field(default=None, description="Whether to notify students of the update.")
+ points_possible: int | None = Field(default=None, description="Maximum points possible on the assignment.")
+ grading_type: str | None = Field(default=None, description="Grading strategy: pass_fail, percent, letter_grade, gpa_scale, points, not_graded.")
+ due_at: str | None = Field(default=None, description="Due date/time in ISO 8601 format (e.g. 2014-10-21T18:48:00Z).")
+ omit_from_final_grade: bool | None = Field(default=None, description="Whether to omit this assignment from the student's final grade.")
+ allowed_attempts: int | None = Field(default=None, description="Number of submission attempts allowed (-1 for unlimited).")
+
+
+# --- @tool functions ------------------------------------------------------
+
+
+@tool(args_schema=ListAccountsInput)
+@serialize_pydantic_return
+async def list_accounts(
+ auth_type: str,
+ auth_data: dict[str, Any],
+) -> ListAccountsOutput:
+ """List Canvas accounts accessible to the authenticated user."""
+ err = _validate_auth(auth_data)
+ if err:
+ return ListAccountsOutput(success=False, error=err)
+ base = _base_url(auth_data)
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{base}/accounts",
+ headers=_headers(auth_data),
+ )
+ if response.status_code != 200:
+ return ListAccountsOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return ListAccountsOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return ListAccountsOutput(success=False, error=f"Call failed: {exc}")
+
+ accounts = [
+ AccountOption(id=a.get("id"), name=a.get("name"))
+ for a in data
+ if isinstance(a, dict)
+ ]
+ return ListAccountsOutput(success=True, accounts=accounts)
+
+
+@tool(args_schema=ListAssignmentsInput)
+@serialize_pydantic_return
+async def list_assignments(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ user_id: str,
+ course_id: str,
+) -> ListAssignmentsOutput:
+ """Retrieve a list of assignments for a user in a specific course."""
+ err = _validate_auth(auth_data)
+ if err:
+ return ListAssignmentsOutput(success=False, error=err)
+ base = _base_url(auth_data)
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{base}/users/{user_id}/courses/{course_id}/assignments",
+ headers=_headers(auth_data),
+ )
+ if response.status_code != 200:
+ return ListAssignmentsOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return ListAssignmentsOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return ListAssignmentsOutput(success=False, error=f"Call failed: {exc}")
+
+ assignments = [
+ AssignmentSummary(
+ id=a.get("id"),
+ name=a.get("name"),
+ description=a.get("description"),
+ due_at=a.get("due_at"),
+ points_possible=a.get("points_possible"),
+ grading_type=a.get("grading_type"),
+ submission_types=a.get("submission_types") or [],
+ course_id=a.get("course_id"),
+ allowed_attempts=a.get("allowed_attempts"),
+ omit_from_final_grade=a.get("omit_from_final_grade"),
+ )
+ for a in data
+ if isinstance(a, dict)
+ ]
+ return ListAssignmentsOutput(success=True, assignments=assignments)
+
+
+@tool(args_schema=ListCoursesInput)
+@serialize_pydantic_return
+async def list_courses(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ user_id: str,
+) -> ListCoursesOutput:
+ """List all courses associated with a given user."""
+ err = _validate_auth(auth_data)
+ if err:
+ return ListCoursesOutput(success=False, error=err)
+ base = _base_url(auth_data)
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{base}/users/{user_id}/courses",
+ headers=_headers(auth_data),
+ )
+ if response.status_code != 200:
+ return ListCoursesOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return ListCoursesOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return ListCoursesOutput(success=False, error=f"Call failed: {exc}")
+
+ courses = [
+ CourseSummary(
+ id=c.get("id"),
+ name=c.get("name"),
+ course_code=c.get("course_code"),
+ workflow_state=c.get("workflow_state"),
+ enrollment_term_id=c.get("enrollment_term_id"),
+ )
+ for c in data
+ if isinstance(c, dict)
+ ]
+ return ListCoursesOutput(success=True, courses=courses)
+
+
+@tool(args_schema=SearchCourseContentInput)
+@serialize_pydantic_return
+async def search_course_content(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ course_id: str,
+ query: str,
+) -> SearchCourseContentOutput:
+ """Search for content in a course using Canvas smart search."""
+ err = _validate_auth(auth_data)
+ if err:
+ return SearchCourseContentOutput(success=False, error=err)
+ base = _base_url(auth_data)
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{base}/courses/{course_id}/smartsearch",
+ headers=_headers(auth_data),
+ params={"q": query},
+ )
+ if response.status_code != 200:
+ return SearchCourseContentOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return SearchCourseContentOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return SearchCourseContentOutput(success=False, error=f"Call failed: {exc}")
+
+ results = [
+ SearchResultItem(
+ content_id=r.get("content_id"),
+ content_type=r.get("content_type"),
+ title=r.get("title"),
+ body=r.get("body"),
+ html_url=r.get("html_url"),
+ distance=r.get("distance"),
+ readable_type=r.get("readable_type"),
+ relevance=r.get("relevance"),
+ )
+ for r in (data if isinstance(data, list) else data.get("results", []))
+ if isinstance(r, dict)
+ ]
+ return SearchCourseContentOutput(success=True, results=results)
+
+
+@tool(args_schema=UpdateAssignmentInput)
+@serialize_pydantic_return
+async def update_assignment(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ course_id: str,
+ assignment_id: str,
+ name: str | None = None,
+ description: str | None = None,
+ submission_type: str | None = None,
+ notify_of_update: bool | None = None,
+ points_possible: int | None = None,
+ grading_type: str | None = None,
+ due_at: str | None = None,
+ omit_from_final_grade: bool | None = None,
+ allowed_attempts: int | None = None,
+) -> UpdateAssignmentOutput:
+ """Update an existing assignment in a course."""
+ err = _validate_auth(auth_data)
+ if err:
+ return UpdateAssignmentOutput(success=False, error=err)
+
+ assignment_body: dict[str, Any] = {}
+ if name is not None:
+ assignment_body["name"] = name
+ if description is not None:
+ assignment_body["description"] = description
+ if submission_type is not None:
+ assignment_body["submission_types"] = [submission_type]
+ if notify_of_update is not None:
+ assignment_body["notify_of_update"] = notify_of_update
+ if points_possible is not None:
+ assignment_body["points_possible"] = points_possible
+ if grading_type is not None:
+ assignment_body["grading_type"] = grading_type
+ if due_at is not None:
+ assignment_body["due_at"] = due_at
+ if omit_from_final_grade is not None:
+ assignment_body["omit_from_final_grade"] = omit_from_final_grade
+ if allowed_attempts is not None:
+ assignment_body["allowed_attempts"] = allowed_attempts
+
+ if not assignment_body:
+ return UpdateAssignmentOutput(
+ success=False,
+ error="At least one field to update must be provided.",
+ )
+
+ base = _base_url(auth_data)
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.put(
+ f"{base}/courses/{course_id}/assignments/{assignment_id}",
+ headers=_headers(auth_data),
+ json={"assignment": assignment_body},
+ )
+ if response.status_code != 200:
+ return UpdateAssignmentOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return UpdateAssignmentOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return UpdateAssignmentOutput(success=False, error=f"Call failed: {exc}")
+
+ a = data
+ return UpdateAssignmentOutput(
+ success=True,
+ assignment=AssignmentSummary(
+ id=a.get("id"),
+ name=a.get("name"),
+ description=a.get("description"),
+ due_at=a.get("due_at"),
+ points_possible=a.get("points_possible"),
+ grading_type=a.get("grading_type"),
+ submission_types=a.get("submission_types") or [],
+ course_id=a.get("course_id"),
+ allowed_attempts=a.get("allowed_attempts"),
+ omit_from_final_grade=a.get("omit_from_final_grade"),
+ ),
+ )
diff --git a/src/modulex_integrations/tools/cogmento/README.md b/src/modulex_integrations/tools/cogmento/README.md
new file mode 100644
index 0000000..e4f3677
--- /dev/null
+++ b/src/modulex_integrations/tools/cogmento/README.md
@@ -0,0 +1,32 @@
+# Cogmento
+
+CRM platform for managing contacts, deals, and tasks via the Cogmento REST API (`api.cogmento.com/api/1`).
+
+## Authentication
+
+### OAuth2 Authentication
+
+- Connect via Cogmento's OAuth 2.0 flow (recommended).
+- Register an OAuth app at Cogmento's developer portal; redirect URI must be `https://api.modulex.dev/credentials/oauth2/callback`.
+- Required env vars (custom OAuth app only): `COGMENTO_OAUTH2_CLIENT_ID`, `COGMENTO_OAUTH2_CLIENT_SECRET`.
+- Note: Cogmento uses a `Token` prefix (not `Bearer`) for the Authorization header.
+
+## Tools
+
+| name | description | required params |
+| --- | --- | --- |
+| `create_contact` | Create a new contact in Cogmento CRM | `first_name`, `last_name` |
+| `create_deal` | Create a new deal in Cogmento CRM | `title` |
+| `create_task` | Create a new task in Cogmento CRM | `title` |
+| `list_user_ids_options` | Retrieve available user options for assignment fields | (none) |
+
+Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fills in from the resolved OAuth credential.
+
+## Limits & Quotas
+
+- No documented rate limits from Cogmento's public API documentation.
+- Error model: non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising.
+
+## Maintainer
+
+ModuleX core team.
diff --git a/src/modulex_integrations/tools/cogmento/__init__.py b/src/modulex_integrations/tools/cogmento/__init__.py
new file mode 100644
index 0000000..4784eff
--- /dev/null
+++ b/src/modulex_integrations/tools/cogmento/__init__.py
@@ -0,0 +1,24 @@
+"""Cogmento integration — discovered via the ``modulex.tools`` entry point."""
+from modulex_integrations.tools.cogmento.manifest import manifest
+from modulex_integrations.tools.cogmento.tools import (
+ create_contact,
+ create_deal,
+ create_task,
+ list_user_ids_options,
+)
+
+TOOLS = (
+ create_contact,
+ create_deal,
+ create_task,
+ list_user_ids_options,
+)
+
+__all__ = [
+ "TOOLS",
+ "create_contact",
+ "create_deal",
+ "create_task",
+ "list_user_ids_options",
+ "manifest",
+]
diff --git a/src/modulex_integrations/tools/cogmento/dependencies.toml b/src/modulex_integrations/tools/cogmento/dependencies.toml
new file mode 100644
index 0000000..a0999bc
--- /dev/null
+++ b/src/modulex_integrations/tools/cogmento/dependencies.toml
@@ -0,0 +1,3 @@
+# Runtime dependencies for the cogmento integration.
+# CI assembles this into the root pyproject's [project.optional-dependencies].
+dependencies = []
diff --git a/src/modulex_integrations/tools/cogmento/manifest.py b/src/modulex_integrations/tools/cogmento/manifest.py
new file mode 100644
index 0000000..741ac7e
--- /dev/null
+++ b/src/modulex_integrations/tools/cogmento/manifest.py
@@ -0,0 +1,185 @@
+"""Cogmento integration manifest."""
+from __future__ import annotations
+
+from modulex_integrations.schema import (
+ ActionDefinition,
+ EnvVar,
+ IntegrationManifest,
+ OAuth2AuthSchema,
+ OAuthConfig,
+ ParameterDef,
+ SuccessIndicators,
+ TestEndpoint,
+)
+
+__all__ = ["manifest"]
+
+
+manifest = IntegrationManifest(
+ name="cogmento",
+ display_name="Cogmento",
+ description="CRM platform for managing contacts, deals, and tasks",
+ version="1.0.0",
+ author="ModuleX",
+ logo="modulex:cogmento",
+ app_url="https://www.cogmento.com",
+ categories=["CRM", "Sales", "Productivity"],
+ actions=[
+ ActionDefinition(
+ name="create_contact",
+ description="Create a new contact in Cogmento CRM",
+ parameters={
+ "first_name": ParameterDef(
+ type="string",
+ description="First name of the contact",
+ required=True,
+ ),
+ "last_name": ParameterDef(
+ type="string",
+ description="Last name of the contact",
+ required=True,
+ ),
+ "email": ParameterDef(
+ type="string",
+ description="Email address of the contact",
+ ),
+ "phone": ParameterDef(
+ type="string",
+ description="Phone number of the contact",
+ ),
+ "description": ParameterDef(
+ type="string",
+ description="Description of the contact",
+ ),
+ "tags": ParameterDef(
+ type="array",
+ description="List of tags (strings) associated with the contact",
+ ),
+ "do_not_call": ParameterDef(
+ type="boolean",
+ description="Set to true to mark the contact as Do Not Call",
+ ),
+ "do_not_text": ParameterDef(
+ type="boolean",
+ description="Set to true to mark the contact as Do Not Text",
+ ),
+ "do_not_email": ParameterDef(
+ type="boolean",
+ description="Set to true to mark the contact as Do Not Email",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="create_deal",
+ description="Create a new deal in Cogmento CRM",
+ parameters={
+ "title": ParameterDef(
+ type="string",
+ description="The title of the deal",
+ required=True,
+ ),
+ "description": ParameterDef(
+ type="string",
+ description="A description of the deal",
+ ),
+ "assignee_ids": ParameterDef(
+ type="array",
+ description="List of user IDs (strings) to assign to the deal",
+ ),
+ "tags": ParameterDef(
+ type="array",
+ description="List of tags (strings) associated with the deal",
+ ),
+ "close_date": ParameterDef(
+ type="string",
+ description="The date the deal was completed (format: YYYY-MM-DD)",
+ ),
+ "product_ids": ParameterDef(
+ type="array",
+ description="List of product IDs (strings) to include in the deal",
+ ),
+ "amount": ParameterDef(
+ type="string",
+ description="The final deal value (numeric string)",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="create_task",
+ description="Create a new task in Cogmento CRM",
+ parameters={
+ "title": ParameterDef(
+ type="string",
+ description="The title of the task",
+ required=True,
+ ),
+ "description": ParameterDef(
+ type="string",
+ description="A description of the task",
+ ),
+ "due_date": ParameterDef(
+ type="string",
+ description="The task's deadline (format: YYYY-MM-DD)",
+ ),
+ "assignee_ids": ParameterDef(
+ type="array",
+ description="List of user IDs (strings) to assign to the task",
+ ),
+ "deal_id": ParameterDef(
+ type="string",
+ description="Identifier of a deal to associate with the task",
+ ),
+ "contact_id": ParameterDef(
+ type="string",
+ description="Identifier of a contact to associate with the task",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="list_user_ids_options",
+ description="Retrieve available user options for assignment fields",
+ parameters={},
+ ),
+ ],
+ auth_schemas=[
+ OAuth2AuthSchema(
+ display_name="OAuth2 Authentication",
+ description="Connect using Cogmento OAuth (recommended)",
+ setup_environment_variables=[
+ EnvVar(
+ name="COGMENTO_OAUTH2_CLIENT_ID",
+ display_name="Client ID",
+ description="Cogmento OAuth App Client ID",
+ required=True,
+ sensitive=False,
+ only_for_custom=True,
+ ),
+ EnvVar(
+ name="COGMENTO_OAUTH2_CLIENT_SECRET",
+ display_name="Client Secret",
+ description="Cogmento OAuth App Client Secret",
+ required=True,
+ sensitive=True,
+ only_for_custom=True,
+ ),
+ ],
+ oauth_config=OAuthConfig(
+ auth_url="https://www.cogmento.com/oauth/authorize",
+ token_url="https://www.cogmento.com/oauth/token",
+ scopes=[],
+ ),
+ test_endpoint=TestEndpoint(
+ url="https://api.cogmento.com/api/1/auth/user",
+ method="GET",
+ headers={
+ "Authorization": "Token {access_token}",
+ },
+ success_indicators=SuccessIndicators(
+ status_codes=[200],
+ ),
+ cost_level="free",
+ description="Validates OAuth token by fetching authenticated user info",
+ ),
+ ),
+ ],
+)
diff --git a/src/modulex_integrations/tools/cogmento/outputs.py b/src/modulex_integrations/tools/cogmento/outputs.py
new file mode 100644
index 0000000..9ef5361
--- /dev/null
+++ b/src/modulex_integrations/tools/cogmento/outputs.py
@@ -0,0 +1,57 @@
+"""Pydantic response models for the cogmento integration's @tool functions."""
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field
+
+__all__ = [
+ "CreateContactOutput",
+ "CreateDealOutput",
+ "CreateTaskOutput",
+ "ListUserIdsOptionsOutput",
+ "UserOption",
+]
+
+
+class _Base(BaseModel):
+ """Shared config for every output model in this integration."""
+
+ model_config = ConfigDict(extra="forbid")
+
+
+# --- Nested resource models -----------------------------------------------
+
+
+class UserOption(_Base):
+ """A user option with label and value."""
+
+ label: str | None = None
+ value: str | None = None
+
+
+# --- Per-action output models ---------------------------------------------
+
+
+class CreateContactOutput(_Base):
+ success: bool
+ error: str | None = None
+ contact: dict[str, Any] | None = None
+
+
+class CreateDealOutput(_Base):
+ success: bool
+ error: str | None = None
+ deal: dict[str, Any] | None = None
+
+
+class CreateTaskOutput(_Base):
+ success: bool
+ error: str | None = None
+ task: dict[str, Any] | None = None
+
+
+class ListUserIdsOptionsOutput(_Base):
+ success: bool
+ error: str | None = None
+ users: list[UserOption] = Field(default_factory=list)
diff --git a/src/modulex_integrations/tools/cogmento/tests/__init__.py b/src/modulex_integrations/tools/cogmento/tests/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/modulex_integrations/tools/cogmento/tests/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/modulex_integrations/tools/cogmento/tests/test_cogmento.py b/src/modulex_integrations/tools/cogmento/tests/test_cogmento.py
new file mode 100644
index 0000000..fe93f23
--- /dev/null
+++ b/src/modulex_integrations/tools/cogmento/tests/test_cogmento.py
@@ -0,0 +1,149 @@
+"""Happy-path tests for every cogmento @tool, plus a manifest sanity check."""
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from modulex_integrations.tools.cogmento import (
+ TOOLS,
+ create_contact,
+ create_deal,
+ create_task,
+ list_user_ids_options,
+ manifest,
+)
+from modulex_integrations.tools.cogmento.outputs import (
+ CreateContactOutput,
+ CreateDealOutput,
+ CreateTaskOutput,
+ ListUserIdsOptionsOutput,
+)
+
+API = "https://api.cogmento.com/api/1"
+
+_AUTH: dict[str, Any] = {
+ "auth_type": "oauth2",
+ "auth_data": {"access_token": "fake_access_token"},
+}
+
+
+def _args(**extra: Any) -> dict[str, Any]:
+ """Build a ``.ainvoke()`` input dict: auth + per-test extras."""
+ return dict(_AUTH, **extra)
+
+
+# --- Manifest sanity --------------------------------------------------------
+
+
+class TestManifest:
+ def test_manifest_exposes_4_actions(self) -> None:
+ assert len(manifest.actions) == 4
+
+ def test_manifest_actions_match_tools_tuple(self) -> None:
+ assert {a.name for a in manifest.actions} == {t.name for t in TOOLS}
+
+ def test_manifest_has_oauth2_auth(self) -> None:
+ assert {a.auth_type for a in manifest.auth_schemas} == {"oauth2"}
+
+
+# --- Per-action happy-path tests -------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_create_contact(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/contacts/",
+ json={
+ # TODO: fill in a representative response shape from Cogmento API docs
+ "id": "abc123",
+ "first_name": "John",
+ "last_name": "Doe",
+ },
+ )
+
+ result_dict = await create_contact.ainvoke(
+ _args(first_name="John", last_name="Doe")
+ )
+
+ assert isinstance(result_dict, dict)
+ result = CreateContactOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.contact is not None
+
+
+@pytest.mark.asyncio
+async def test_create_deal(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/deals/",
+ json={
+ # TODO: fill in a representative response shape from Cogmento API docs
+ "id": "deal456",
+ "title": "New Deal",
+ },
+ )
+
+ result_dict = await create_deal.ainvoke(_args(title="New Deal"))
+
+ assert isinstance(result_dict, dict)
+ result = CreateDealOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.deal is not None
+
+
+@pytest.mark.asyncio
+async def test_create_task(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/tasks/",
+ json={
+ # TODO: fill in a representative response shape from Cogmento API docs
+ "id": "task789",
+ "title": "Follow up",
+ },
+ )
+
+ result_dict = await create_task.ainvoke(_args(title="Follow up"))
+
+ assert isinstance(result_dict, dict)
+ result = CreateTaskOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.task is not None
+
+
+@pytest.mark.asyncio
+async def test_list_user_ids_options(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/auth/user",
+ json=[
+ # TODO: fill in a representative response shape from Cogmento API docs
+ {"id": "u1", "name": "Alice", "email": "alice@example.com"},
+ {"id": "u2", "name": "Bob", "email": "bob@example.com"},
+ ],
+ )
+
+ result_dict = await list_user_ids_options.ainvoke(_args())
+
+ assert isinstance(result_dict, dict)
+ result = ListUserIdsOptionsOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.users) == 2
+
+
+# --- Failure-path tests ----------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_create_contact_empty_credential(): # type: ignore[no-untyped-def]
+ """Empty access_token should return success=False without hitting the network."""
+ result_dict = await create_contact.ainvoke(
+ _args(auth_data={"access_token": ""}, first_name="Jane", last_name="Doe")
+ )
+
+ assert isinstance(result_dict, dict)
+ result = CreateContactOutput.model_validate(result_dict)
+ assert result.success is False
+ assert result.error is not None
diff --git a/src/modulex_integrations/tools/cogmento/tools.py b/src/modulex_integrations/tools/cogmento/tools.py
new file mode 100644
index 0000000..9a935ab
--- /dev/null
+++ b/src/modulex_integrations/tools/cogmento/tools.py
@@ -0,0 +1,301 @@
+"""Cogmento LangChain @tool functions."""
+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.cogmento.outputs import (
+ CreateContactOutput,
+ CreateDealOutput,
+ CreateTaskOutput,
+ ListUserIdsOptionsOutput,
+ UserOption,
+)
+
+__all__ = [
+ "create_contact",
+ "create_deal",
+ "create_task",
+ "list_user_ids_options",
+]
+
+_BASE_URL = "https://api.cogmento.com/api/1"
+
+
+def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]:
+ """Build headers for the Cogmento API based on auth_type/auth_data."""
+ headers: dict[str, str] = {"Accept": "application/json"}
+ if auth_type == "oauth2":
+ access_token = auth_data.get("access_token")
+ if access_token:
+ headers["Authorization"] = f"Token {access_token}"
+ return headers
+
+
+# --- Input schemas --------------------------------------------------------
+
+
+class CreateContactInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ first_name: str = Field(description="First name of the contact")
+ last_name: str = Field(description="Last name of the contact")
+ email: str | None = Field(default=None, description="Email address of the contact")
+ phone: str | None = Field(default=None, description="Phone number of the contact")
+ description: str | None = Field(default=None, description="Description of the contact")
+ tags: list[str] | None = Field(default=None, description="List of tags associated with the contact")
+ do_not_call: bool | None = Field(default=None, description="Set to true to mark as Do Not Call")
+ do_not_text: bool | None = Field(default=None, description="Set to true to mark as Do Not Text")
+ do_not_email: bool | None = Field(default=None, description="Set to true to mark as Do Not Email")
+
+
+class CreateDealInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ title: str = Field(description="The title of the deal")
+ description: str | None = Field(default=None, description="A description of the deal")
+ assignee_ids: list[str] | None = Field(default=None, description="List of user IDs to assign to the deal")
+ tags: list[str] | None = Field(default=None, description="List of tags associated with the deal")
+ close_date: str | None = Field(default=None, description="The date the deal was completed (YYYY-MM-DD)")
+ product_ids: list[str] | None = Field(default=None, description="List of product IDs to include in the deal")
+ amount: str | None = Field(default=None, description="The final deal value (numeric string)")
+
+
+class CreateTaskInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ title: str = Field(description="The title of the task")
+ description: str | None = Field(default=None, description="A description of the task")
+ due_date: str | None = Field(default=None, description="The task's deadline (YYYY-MM-DD)")
+ assignee_ids: list[str] | None = Field(default=None, description="List of user IDs to assign to the task")
+ deal_id: str | None = Field(default=None, description="Identifier of a deal to associate with the task")
+ contact_id: str | None = Field(default=None, description="Identifier of a contact to associate with the task")
+
+
+class ListUserIdsOptionsInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+
+
+# --- @tool functions ------------------------------------------------------
+
+
+@tool(args_schema=CreateContactInput)
+@serialize_pydantic_return
+async def create_contact(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ first_name: str,
+ last_name: str,
+ email: str | None = None,
+ phone: str | None = None,
+ description: str | None = None,
+ tags: list[str] | None = None,
+ do_not_call: bool | None = None,
+ do_not_text: bool | None = None,
+ do_not_email: bool | None = None,
+) -> CreateContactOutput:
+ """Create a new contact in Cogmento CRM"""
+ access_token = auth_data.get("access_token")
+ if not access_token or not access_token.strip():
+ return CreateContactOutput(success=False, error="Missing or empty access_token in auth_data.")
+ headers = _get_auth_headers(auth_type, auth_data)
+ headers["Content-Type"] = "application/json"
+
+ payload: dict[str, Any] = {
+ "first_name": first_name,
+ "last_name": last_name,
+ }
+
+ channels: list[dict[str, str]] = []
+ if email:
+ channels.append({"channel_type": "email", "value": email})
+ if phone:
+ channels.append({"channel_type": "phone", "value": phone})
+ if channels:
+ payload["channels"] = channels
+
+ if description is not None:
+ payload["description"] = description
+ if tags is not None:
+ payload["tags"] = tags
+ if do_not_call is not None:
+ payload["do_not_call"] = do_not_call
+ if do_not_text is not None:
+ payload["do_not_text"] = do_not_text
+ if do_not_email is not None:
+ payload["do_not_email"] = do_not_email
+
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.post(
+ f"{_BASE_URL}/contacts/",
+ headers=headers,
+ json=payload,
+ )
+ if response.status_code not in (200, 201):
+ return CreateContactOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return CreateContactOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return CreateContactOutput(success=False, error=f"Call failed: {exc}")
+
+ return CreateContactOutput(success=True, contact=data)
+
+
+@tool(args_schema=CreateDealInput)
+@serialize_pydantic_return
+async def create_deal(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ title: str,
+ description: str | None = None,
+ assignee_ids: list[str] | None = None,
+ tags: list[str] | None = None,
+ close_date: str | None = None,
+ product_ids: list[str] | None = None,
+ amount: str | None = None,
+) -> CreateDealOutput:
+ """Create a new deal in Cogmento CRM"""
+ access_token = auth_data.get("access_token")
+ if not access_token or not access_token.strip():
+ return CreateDealOutput(success=False, error="Missing or empty access_token in auth_data.")
+ headers = _get_auth_headers(auth_type, auth_data)
+ headers["Content-Type"] = "application/json"
+
+ payload: dict[str, Any] = {"title": title}
+
+ if description is not None:
+ payload["description"] = description
+ if assignee_ids is not None:
+ payload["assigned_to"] = [{"id": uid} for uid in assignee_ids]
+ if tags is not None:
+ payload["tags"] = tags
+ if close_date is not None:
+ payload["close_date"] = close_date
+ if product_ids is not None:
+ payload["products"] = [{"id": pid} for pid in product_ids]
+ if amount is not None:
+ payload["amount"] = float(amount)
+
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.post(
+ f"{_BASE_URL}/deals/",
+ headers=headers,
+ json=payload,
+ )
+ if response.status_code not in (200, 201):
+ return CreateDealOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return CreateDealOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return CreateDealOutput(success=False, error=f"Call failed: {exc}")
+
+ return CreateDealOutput(success=True, deal=data)
+
+
+@tool(args_schema=CreateTaskInput)
+@serialize_pydantic_return
+async def create_task(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ title: str,
+ description: str | None = None,
+ due_date: str | None = None,
+ assignee_ids: list[str] | None = None,
+ deal_id: str | None = None,
+ contact_id: str | None = None,
+) -> CreateTaskOutput:
+ """Create a new task in Cogmento CRM"""
+ access_token = auth_data.get("access_token")
+ if not access_token or not access_token.strip():
+ return CreateTaskOutput(success=False, error="Missing or empty access_token in auth_data.")
+ headers = _get_auth_headers(auth_type, auth_data)
+ headers["Content-Type"] = "application/json"
+
+ payload: dict[str, Any] = {"title": title}
+
+ if description is not None:
+ payload["description"] = description
+ if due_date is not None:
+ payload["due_date"] = due_date
+ if assignee_ids is not None:
+ payload["assigned_to"] = [{"id": uid} for uid in assignee_ids]
+ if deal_id is not None:
+ payload["deal"] = {"id": deal_id}
+ if contact_id is not None:
+ payload["contact"] = {"id": contact_id}
+
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.post(
+ f"{_BASE_URL}/tasks/",
+ headers=headers,
+ json=payload,
+ )
+ if response.status_code not in (200, 201):
+ return CreateTaskOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return CreateTaskOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return CreateTaskOutput(success=False, error=f"Call failed: {exc}")
+
+ return CreateTaskOutput(success=True, task=data)
+
+
+@tool(args_schema=ListUserIdsOptionsInput)
+@serialize_pydantic_return
+async def list_user_ids_options(
+ auth_type: str,
+ auth_data: dict[str, Any],
+) -> ListUserIdsOptionsOutput:
+ """Retrieve available user options for assignment fields"""
+ access_token = auth_data.get("access_token")
+ if not access_token or not access_token.strip():
+ return ListUserIdsOptionsOutput(success=False, error="Missing or empty access_token in auth_data.")
+ headers = _get_auth_headers(auth_type, auth_data)
+
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.get(
+ f"{_BASE_URL}/auth/user",
+ headers=headers,
+ )
+ if response.status_code != 200:
+ return ListUserIdsOptionsOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return ListUserIdsOptionsOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return ListUserIdsOptionsOutput(success=False, error=f"Call failed: {exc}")
+
+ users_list = data if isinstance(data, list) else [data]
+ users = [
+ UserOption(
+ label=u.get("name") or u.get("email", ""),
+ value=str(u.get("id", "")),
+ )
+ for u in users_list
+ ]
+
+ return ListUserIdsOptionsOutput(success=True, users=users)
diff --git a/src/modulex_integrations/tools/coinmarketcap/README.md b/src/modulex_integrations/tools/coinmarketcap/README.md
new file mode 100644
index 0000000..2853e5a
--- /dev/null
+++ b/src/modulex_integrations/tools/coinmarketcap/README.md
@@ -0,0 +1,34 @@
+# CoinMarketCap
+
+Cryptocurrency market data, quotes, and metadata from the CoinMarketCap REST API (`pro-api.coinmarketcap.com`).
+
+## Authentication
+
+### API Key Authentication
+
+- Sign up at to get your API key.
+- Required env var: `COINMARKETCAP_API_KEY` (format: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`).
+- The API key is passed via the `X-CMC_PRO_API_KEY` header on every request.
+
+## Tools
+
+| name | description | required params |
+| --- | --- | --- |
+| `get_cryptocurrency_metadata` | Returns all static metadata available for one or more cryptocurrencies | `ids` |
+| `id_map` | Returns a mapping of all cryptocurrencies to unique CoinMarketCap IDs | _(none)_ |
+| `latest_listings` | Returns a paginated list of all active cryptocurrencies with latest market data | _(none)_ |
+| `latest_quotes` | Returns the latest market quote for one or more cryptocurrencies | _(none — but at least one of id, slug, or symbol must be provided)_ |
+
+Every tool takes an additional `api_key` parameter that the runtime fills in from the resolved credential.
+
+## Limits & Quotas
+
+- **Basic (free) plan**: 333 calls/day, 10,000 calls/month.
+- **Hobbyist plan**: 10,000 calls/month.
+- **Standard plan and above**: higher limits per the pricing page.
+- Rate limiting is applied per API key. Exceeding limits returns HTTP 429.
+- **Error model**: non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising.
+
+## Maintainer
+
+ModuleX core team.
diff --git a/src/modulex_integrations/tools/coinmarketcap/__init__.py b/src/modulex_integrations/tools/coinmarketcap/__init__.py
new file mode 100644
index 0000000..50700b3
--- /dev/null
+++ b/src/modulex_integrations/tools/coinmarketcap/__init__.py
@@ -0,0 +1,24 @@
+"""CoinMarketCap integration — discovered via the ``modulex.tools`` entry point."""
+from modulex_integrations.tools.coinmarketcap.manifest import manifest
+from modulex_integrations.tools.coinmarketcap.tools import (
+ get_cryptocurrency_metadata,
+ id_map,
+ latest_listings,
+ latest_quotes,
+)
+
+TOOLS = (
+ get_cryptocurrency_metadata,
+ id_map,
+ latest_listings,
+ latest_quotes,
+)
+
+__all__ = [
+ "TOOLS",
+ "get_cryptocurrency_metadata",
+ "id_map",
+ "latest_listings",
+ "latest_quotes",
+ "manifest",
+]
diff --git a/src/modulex_integrations/tools/coinmarketcap/dependencies.toml b/src/modulex_integrations/tools/coinmarketcap/dependencies.toml
new file mode 100644
index 0000000..a2b951f
--- /dev/null
+++ b/src/modulex_integrations/tools/coinmarketcap/dependencies.toml
@@ -0,0 +1,3 @@
+# Runtime dependencies for the coinmarketcap integration.
+# CI assembles this into the root pyproject's [project.optional-dependencies].
+dependencies = []
diff --git a/src/modulex_integrations/tools/coinmarketcap/manifest.py b/src/modulex_integrations/tools/coinmarketcap/manifest.py
new file mode 100644
index 0000000..7492524
--- /dev/null
+++ b/src/modulex_integrations/tools/coinmarketcap/manifest.py
@@ -0,0 +1,182 @@
+"""CoinMarketCap integration manifest."""
+from __future__ import annotations
+
+from modulex_integrations.schema import (
+ ActionDefinition,
+ ApiKeyAuthSchema,
+ EnvVar,
+ IntegrationManifest,
+ ParameterDef,
+ SuccessIndicators,
+ TestEndpoint,
+)
+
+__all__ = ["manifest"]
+
+
+manifest = IntegrationManifest(
+ name="coinmarketcap",
+ display_name="CoinMarketCap",
+ description="Cryptocurrency market data, quotes, and metadata from the CoinMarketCap API",
+ version="1.0.0",
+ author="ModuleX",
+ logo="modulex:coinmarketcap-themed",
+ app_url="https://coinmarketcap.com",
+ categories=["Finance", "Cryptocurrency", "Market Data"],
+ actions=[
+ ActionDefinition(
+ name="get_cryptocurrency_metadata",
+ description="Returns all static metadata available for one or more cryptocurrencies including name, symbol, logo, description, and URLs",
+ parameters={
+ "ids": ParameterDef(
+ type="string",
+ description="One or more comma-separated CoinMarketCap cryptocurrency IDs. Example: 1,2,1027",
+ required=True,
+ ),
+ "skip_invalid": ParameterDef(
+ type="boolean",
+ description="When true, invalid lookups will be skipped allowing valid cryptocurrencies to still be returned",
+ default=False,
+ ),
+ "aux": ParameterDef(
+ type="string",
+ description="Comma-separated supplemental data fields to return. Valid values: urls, logo, description, tags, platform, date_added, notice, status",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="id_map",
+ description="Returns a mapping of all cryptocurrencies to unique CoinMarketCap IDs",
+ parameters={
+ "listing_status": ParameterDef(
+ type="string",
+ description="Filter by status. Valid values: active, inactive, untracked. Comma-separated for multiple.",
+ ),
+ "start": ParameterDef(
+ type="integer",
+ description="Offset the start (1-based index) of the paginated list of items to return",
+ ),
+ "limit": ParameterDef(
+ type="integer",
+ description="Number of results to return. Default 100",
+ default=100,
+ ),
+ "sort": ParameterDef(
+ type="string",
+ description="Sort field. Valid values: cmc_rank, id",
+ ),
+ "symbol": ParameterDef(
+ type="string",
+ description="Comma-separated list of cryptocurrency symbols to return IDs for. If passed, other options are ignored.",
+ ),
+ "aux": ParameterDef(
+ type="string",
+ description="Comma-separated supplemental data fields. Valid values: platform, first_historical_data, last_historical_data, is_active, status",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="latest_listings",
+ description="Returns a paginated list of all active cryptocurrencies with latest market data",
+ parameters={
+ "start": ParameterDef(
+ type="integer",
+ description="Offset the start (1-based index) of the paginated list of items to return",
+ ),
+ "limit": ParameterDef(
+ type="integer",
+ description="Number of results to return",
+ ),
+ "volume_24h_min": ParameterDef(
+ type="number",
+ description="Minimum 24 hour USD volume to filter results by",
+ ),
+ "convert": ParameterDef(
+ type="string",
+ description="Comma-separated list of cryptocurrency or fiat currency symbols to calculate market quotes in",
+ ),
+ "convert_id": ParameterDef(
+ type="string",
+ description="Comma-separated CoinMarketCap IDs to calculate market quotes in. Cannot be used with convert.",
+ ),
+ "sort": ParameterDef(
+ type="string",
+ description="Sort field. Valid values: market_cap, name, symbol, date_added, price, circulating_supply, total_supply, max_supply, num_market_pairs, volume_24h, percent_change_1h, percent_change_24h, percent_change_7d",
+ ),
+ "sort_dir": ParameterDef(
+ type="string",
+ description="Sort direction. Valid values: asc, desc",
+ ),
+ "cryptocurrency_type": ParameterDef(
+ type="string",
+ description="Type of cryptocurrency to include. Valid values: all, coins, tokens",
+ ),
+ "aux": ParameterDef(
+ type="string",
+ description="Comma-separated supplemental data fields. Valid values: num_market_pairs, cmc_rank, date_added, tags, platform, max_supply, circulating_supply, total_supply",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="latest_quotes",
+ description="Returns the latest market quote for one or more cryptocurrencies. At least one of id, slug, or symbol is required.",
+ parameters={
+ "id": ParameterDef(
+ type="string",
+ description="One or more comma-separated CoinMarketCap cryptocurrency IDs. Example: 1,2",
+ ),
+ "slug": ParameterDef(
+ type="string",
+ description="Comma-separated list of cryptocurrency slugs. Example: bitcoin,ethereum",
+ ),
+ "symbol": ParameterDef(
+ type="string",
+ description="Comma-separated cryptocurrency symbols. Example: BTC,ETH",
+ ),
+ "convert": ParameterDef(
+ type="string",
+ description="Comma-separated list of currency symbols to calculate quotes in",
+ ),
+ "convert_id": ParameterDef(
+ type="string",
+ description="Comma-separated CoinMarketCap IDs to calculate quotes in. Cannot be used with convert.",
+ ),
+ },
+ ),
+ ],
+ auth_schemas=[
+ ApiKeyAuthSchema(
+ display_name="API Key Authentication",
+ description="Authenticate using your CoinMarketCap API key",
+ setup_instructions=[
+ "Go to https://coinmarketcap.com/api/ and sign up for an account",
+ "Navigate to your API dashboard",
+ "Copy your API key",
+ "Paste the API key below",
+ ],
+ setup_environment_variables=[
+ EnvVar(
+ name="COINMARKETCAP_API_KEY",
+ display_name="CoinMarketCap API Key",
+ description="Your CoinMarketCap API key from the developer dashboard",
+ required=True,
+ sensitive=True,
+ sample_format="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
+ about_url="https://coinmarketcap.com/api/",
+ ),
+ ],
+ test_endpoint=TestEndpoint(
+ url="https://pro-api.coinmarketcap.com/v1/cryptocurrency/map",
+ method="GET",
+ headers={"X-CMC_PRO_API_KEY": "{api_key}"},
+ params={"limit": "1"},
+ success_indicators=SuccessIndicators(
+ status_codes=[200],
+ response_fields=["data"],
+ ),
+ cost_level="free",
+ description="Validates the API key by fetching one cryptocurrency mapping entry",
+ ),
+ ),
+ ],
+)
diff --git a/src/modulex_integrations/tools/coinmarketcap/outputs.py b/src/modulex_integrations/tools/coinmarketcap/outputs.py
new file mode 100644
index 0000000..d9dc248
--- /dev/null
+++ b/src/modulex_integrations/tools/coinmarketcap/outputs.py
@@ -0,0 +1,114 @@
+"""Pydantic response models for the coinmarketcap integration's @tool functions."""
+from __future__ import annotations
+
+from pydantic import BaseModel, ConfigDict, Field
+
+__all__ = [
+ "CryptocurrencyMapItem",
+ "CryptocurrencyMetadata",
+ "CryptocurrencyQuote",
+ "GetCryptocurrencyMetadataOutput",
+ "IdMapOutput",
+ "LatestListingsOutput",
+ "LatestQuotesOutput",
+ "ListingItem",
+ "QuoteData",
+]
+
+
+class _Base(BaseModel):
+ """Shared config for every output model in this integration."""
+
+ model_config = ConfigDict(extra="forbid")
+
+
+# --- Nested resource models -----------------------------------------------
+
+
+class CryptocurrencyMetadata(_Base):
+ """Metadata for a single cryptocurrency."""
+
+ id: int | None = None
+ name: str | None = None
+ symbol: str | None = None
+ slug: str | None = None
+ description: str | None = None
+ logo: str | None = None
+ date_added: str | None = None
+ category: str | None = None
+
+
+class CryptocurrencyMapItem(_Base):
+ """A cryptocurrency mapping entry."""
+
+ id: int | None = None
+ name: str | None = None
+ symbol: str | None = None
+ slug: str | None = None
+ is_active: int | None = None
+ first_historical_data: str | None = None
+ last_historical_data: str | None = None
+
+
+class QuoteData(_Base):
+ """Quote data for a single currency conversion."""
+
+ price: float | None = None
+ volume_24h: float | None = None
+ market_cap: float | None = None
+ percent_change_1h: float | None = None
+ percent_change_24h: float | None = None
+ percent_change_7d: float | None = None
+ last_updated: str | None = None
+
+
+class ListingItem(_Base):
+ """A cryptocurrency listing entry with market data."""
+
+ id: int | None = None
+ name: str | None = None
+ symbol: str | None = None
+ slug: str | None = None
+ cmc_rank: int | None = None
+ circulating_supply: float | None = None
+ total_supply: float | None = None
+ max_supply: float | None = None
+ quote: dict[str, QuoteData] = Field(default_factory=dict)
+
+
+class CryptocurrencyQuote(_Base):
+ """Quote data for a specific cryptocurrency."""
+
+ id: int | None = None
+ name: str | None = None
+ symbol: str | None = None
+ slug: str | None = None
+ cmc_rank: int | None = None
+ quote: dict[str, QuoteData] = Field(default_factory=dict)
+
+
+# --- Per-action output models ----------------------------------------------
+
+
+class GetCryptocurrencyMetadataOutput(_Base):
+ success: bool
+ error: str | None = None
+ data: dict[str, CryptocurrencyMetadata] = Field(default_factory=dict)
+
+
+class IdMapOutput(_Base):
+ success: bool
+ error: str | None = None
+ data: list[CryptocurrencyMapItem] = Field(default_factory=list)
+
+
+class LatestListingsOutput(_Base):
+ success: bool
+ error: str | None = None
+ data: list[ListingItem] = Field(default_factory=list)
+
+
+class LatestQuotesOutput(_Base):
+ success: bool
+ error: str | None = None
+ data: dict[str, CryptocurrencyQuote] = Field(default_factory=dict)
diff --git a/src/modulex_integrations/tools/coinmarketcap/tests/__init__.py b/src/modulex_integrations/tools/coinmarketcap/tests/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/modulex_integrations/tools/coinmarketcap/tests/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/modulex_integrations/tools/coinmarketcap/tests/test_coinmarketcap.py b/src/modulex_integrations/tools/coinmarketcap/tests/test_coinmarketcap.py
new file mode 100644
index 0000000..054ed5a
--- /dev/null
+++ b/src/modulex_integrations/tools/coinmarketcap/tests/test_coinmarketcap.py
@@ -0,0 +1,205 @@
+"""Happy-path tests for every coinmarketcap @tool, plus a manifest sanity check."""
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from modulex_integrations.tools.coinmarketcap import (
+ TOOLS,
+ get_cryptocurrency_metadata,
+ id_map,
+ latest_listings,
+ latest_quotes,
+ manifest,
+)
+from modulex_integrations.tools.coinmarketcap.outputs import (
+ GetCryptocurrencyMetadataOutput,
+ IdMapOutput,
+ LatestListingsOutput,
+ LatestQuotesOutput,
+)
+
+API = "https://pro-api.coinmarketcap.com"
+
+_API_KEY = "fake-api-key"
+
+
+def _args(**extra: Any) -> dict[str, Any]:
+ return dict(api_key=_API_KEY, **extra)
+
+
+# --- Manifest sanity --------------------------------------------------------
+
+
+class TestManifest:
+ def test_manifest_exposes_4_actions(self) -> None:
+ assert len(manifest.actions) == 4
+
+ def test_manifest_actions_match_tools_tuple(self) -> None:
+ assert {a.name for a in manifest.actions} == {t.name for t in TOOLS}
+
+ def test_manifest_has_api_key_auth(self) -> None:
+ assert {a.auth_type for a in manifest.auth_schemas} == {"api_key"}
+
+
+# --- Per-action happy-path tests -------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_get_cryptocurrency_metadata(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/v2/cryptocurrency/info?id=1&skip_invalid=false",
+ json={
+ # TODO: fill in a representative response shape from the upstream API docs
+ "data": {
+ "1": {
+ "id": 1,
+ "name": "Bitcoin",
+ "symbol": "BTC",
+ "slug": "bitcoin",
+ "description": "Bitcoin is a cryptocurrency.",
+ "logo": "https://s2.coinmarketcap.com/static/img/coins/64x64/1.png",
+ "date_added": "2013-04-28T00:00:00.000Z",
+ "category": "coin",
+ }
+ }
+ },
+ )
+
+ result_dict = await get_cryptocurrency_metadata.ainvoke(_args(ids="1"))
+
+ assert isinstance(result_dict, dict)
+ result = GetCryptocurrencyMetadataOutput.model_validate(result_dict)
+ assert result.success is True
+ assert "1" in result.data
+ assert result.data["1"].name == "Bitcoin"
+
+
+@pytest.mark.asyncio
+async def test_id_map(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/v1/cryptocurrency/map?limit=100",
+ json={
+ # TODO: fill in a representative response shape from the upstream API docs
+ "data": [
+ {
+ "id": 1,
+ "name": "Bitcoin",
+ "symbol": "BTC",
+ "slug": "bitcoin",
+ "is_active": 1,
+ "first_historical_data": "2013-04-28T18:47:21.000Z",
+ "last_historical_data": "2024-01-01T00:00:00.000Z",
+ }
+ ]
+ },
+ )
+
+ result_dict = await id_map.ainvoke(_args())
+
+ assert isinstance(result_dict, dict)
+ result = IdMapOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.data) == 1
+ assert result.data[0].symbol == "BTC"
+
+
+@pytest.mark.asyncio
+async def test_latest_listings(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/v1/cryptocurrency/listings/latest",
+ json={
+ # TODO: fill in a representative response shape from the upstream API docs
+ "data": [
+ {
+ "id": 1,
+ "name": "Bitcoin",
+ "symbol": "BTC",
+ "slug": "bitcoin",
+ "cmc_rank": 1,
+ "circulating_supply": 19500000.0,
+ "total_supply": 19500000.0,
+ "max_supply": 21000000.0,
+ "quote": {
+ "USD": {
+ "price": 50000.0,
+ "volume_24h": 30000000000.0,
+ "market_cap": 975000000000.0,
+ "percent_change_1h": 0.5,
+ "percent_change_24h": 2.1,
+ "percent_change_7d": -1.3,
+ "last_updated": "2024-01-01T00:00:00.000Z",
+ }
+ },
+ }
+ ]
+ },
+ )
+
+ result_dict = await latest_listings.ainvoke(_args())
+
+ assert isinstance(result_dict, dict)
+ result = LatestListingsOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.data) == 1
+ assert result.data[0].symbol == "BTC"
+ assert result.data[0].quote["USD"].price == 50000.0
+
+
+@pytest.mark.asyncio
+async def test_latest_quotes(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/v1/cryptocurrency/quotes/latest?symbol=BTC",
+ json={
+ # TODO: fill in a representative response shape from the upstream API docs
+ "data": {
+ "BTC": {
+ "id": 1,
+ "name": "Bitcoin",
+ "symbol": "BTC",
+ "slug": "bitcoin",
+ "cmc_rank": 1,
+ "quote": {
+ "USD": {
+ "price": 50000.0,
+ "volume_24h": 30000000000.0,
+ "market_cap": 975000000000.0,
+ "percent_change_1h": 0.5,
+ "percent_change_24h": 2.1,
+ "percent_change_7d": -1.3,
+ "last_updated": "2024-01-01T00:00:00.000Z",
+ }
+ },
+ }
+ }
+ },
+ )
+
+ result_dict = await latest_quotes.ainvoke(_args(symbol="BTC"))
+
+ assert isinstance(result_dict, dict)
+ result = LatestQuotesOutput.model_validate(result_dict)
+ assert result.success is True
+ assert "BTC" in result.data
+ assert result.data["BTC"].quote["USD"].price == 50000.0
+
+
+# --- Failure-path tests ----------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_empty_credential_returns_error(): # type: ignore[no-untyped-def]
+ """Pattern B: empty API key must return success=False without hitting the wire."""
+ result_dict = await get_cryptocurrency_metadata.ainvoke(
+ {"ids": "1", "api_key": ""}
+ )
+
+ assert isinstance(result_dict, dict)
+ result = GetCryptocurrencyMetadataOutput.model_validate(result_dict)
+ assert result.success is False
+ assert result.error is not None
diff --git a/src/modulex_integrations/tools/coinmarketcap/tools.py b/src/modulex_integrations/tools/coinmarketcap/tools.py
new file mode 100644
index 0000000..b0eb0b6
--- /dev/null
+++ b/src/modulex_integrations/tools/coinmarketcap/tools.py
@@ -0,0 +1,355 @@
+"""CoinMarketCap LangChain @tool functions."""
+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.coinmarketcap.outputs import (
+ CryptocurrencyMapItem,
+ CryptocurrencyMetadata,
+ CryptocurrencyQuote,
+ GetCryptocurrencyMetadataOutput,
+ IdMapOutput,
+ LatestListingsOutput,
+ LatestQuotesOutput,
+ ListingItem,
+ QuoteData,
+)
+
+__all__ = [
+ "get_cryptocurrency_metadata",
+ "id_map",
+ "latest_listings",
+ "latest_quotes",
+]
+
+_BASE_URL = "https://pro-api.coinmarketcap.com"
+_TIMEOUT = 30.0
+
+
+def _headers(api_key: str) -> dict[str, str]:
+ return {
+ "X-CMC_PRO_API_KEY": api_key,
+ "Accept": "application/json",
+ }
+
+
+def _parse_quote(raw: dict[str, Any]) -> dict[str, QuoteData]:
+ result: dict[str, QuoteData] = {}
+ for currency, qdata in (raw or {}).items():
+ if isinstance(qdata, dict):
+ result[currency] = QuoteData(
+ price=qdata.get("price"),
+ volume_24h=qdata.get("volume_24h"),
+ market_cap=qdata.get("market_cap"),
+ percent_change_1h=qdata.get("percent_change_1h"),
+ percent_change_24h=qdata.get("percent_change_24h"),
+ percent_change_7d=qdata.get("percent_change_7d"),
+ last_updated=qdata.get("last_updated"),
+ )
+ return result
+
+
+# --- Input schemas --------------------------------------------------------
+
+
+class GetCryptocurrencyMetadataInput(BaseModel):
+ ids: str = Field(description="One or more comma-separated CoinMarketCap cryptocurrency IDs")
+ api_key: str = Field(description="CoinMarketCap API key")
+ skip_invalid: bool = Field(default=False, description="When true, invalid lookups will be skipped")
+ aux: str | None = Field(default=None, description="Comma-separated supplemental data fields to return")
+
+
+class IdMapInput(BaseModel):
+ api_key: str = Field(description="CoinMarketCap API key")
+ listing_status: str | None = Field(default=None, description="Filter by status: active, inactive, untracked")
+ start: int | None = Field(default=None, description="Offset the start (1-based index)")
+ limit: int = Field(default=100, description="Number of results to return")
+ sort: str | None = Field(default=None, description="Sort field: cmc_rank or id")
+ symbol: str | None = Field(default=None, description="Comma-separated cryptocurrency symbols")
+ aux: str | None = Field(default=None, description="Comma-separated supplemental data fields")
+
+
+class LatestListingsInput(BaseModel):
+ api_key: str = Field(description="CoinMarketCap API key")
+ start: int | None = Field(default=None, description="Offset the start (1-based index)")
+ limit: int | None = Field(default=None, description="Number of results to return")
+ volume_24h_min: float | None = Field(default=None, description="Minimum 24 hour USD volume filter")
+ convert: str | None = Field(default=None, description="Comma-separated currency symbols for quotes")
+ convert_id: str | None = Field(default=None, description="Comma-separated CoinMarketCap IDs for quotes")
+ sort: str | None = Field(default=None, description="Sort field")
+ sort_dir: str | None = Field(default=None, description="Sort direction: asc or desc")
+ cryptocurrency_type: str | None = Field(default=None, description="Type filter: all, coins, tokens")
+ aux: str | None = Field(default=None, description="Comma-separated supplemental data fields")
+
+
+class LatestQuotesInput(BaseModel):
+ api_key: str = Field(description="CoinMarketCap API key")
+ id: str | None = Field(default=None, description="Comma-separated CoinMarketCap cryptocurrency IDs")
+ slug: str | None = Field(default=None, description="Comma-separated cryptocurrency slugs")
+ symbol: str | None = Field(default=None, description="Comma-separated cryptocurrency symbols")
+ convert: str | None = Field(default=None, description="Comma-separated currency symbols for quotes")
+ convert_id: str | None = Field(default=None, description="Comma-separated CoinMarketCap IDs for quotes")
+
+
+# --- @tool functions ------------------------------------------------------
+
+
+@tool(args_schema=GetCryptocurrencyMetadataInput)
+@serialize_pydantic_return
+async def get_cryptocurrency_metadata(
+ ids: str,
+ api_key: str,
+ skip_invalid: bool = False,
+ aux: str | None = None,
+) -> GetCryptocurrencyMetadataOutput:
+ """Returns all static metadata available for one or more cryptocurrencies including name, symbol, logo, description, and URLs"""
+ if not api_key or not api_key.strip():
+ return GetCryptocurrencyMetadataOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+ params: dict[str, Any] = {"id": ids, "skip_invalid": str(skip_invalid).lower()}
+ if aux:
+ params["aux"] = aux
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{_BASE_URL}/v2/cryptocurrency/info",
+ headers=_headers(api_key),
+ params=params,
+ )
+ if response.status_code != 200:
+ return GetCryptocurrencyMetadataOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ body = response.json()
+ except httpx.TimeoutException:
+ return GetCryptocurrencyMetadataOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return GetCryptocurrencyMetadataOutput(success=False, error=f"Call failed: {exc}")
+
+ raw_data = body.get("data", {})
+ data: dict[str, CryptocurrencyMetadata] = {}
+ for key, item in raw_data.items():
+ if isinstance(item, dict):
+ data[key] = CryptocurrencyMetadata(
+ id=item.get("id"),
+ name=item.get("name"),
+ symbol=item.get("symbol"),
+ slug=item.get("slug"),
+ description=item.get("description"),
+ logo=item.get("logo"),
+ date_added=item.get("date_added"),
+ category=item.get("category"),
+ )
+ return GetCryptocurrencyMetadataOutput(success=True, data=data)
+
+
+@tool(args_schema=IdMapInput)
+@serialize_pydantic_return
+async def id_map(
+ api_key: str,
+ listing_status: str | None = None,
+ start: int | None = None,
+ limit: int = 100,
+ sort: str | None = None,
+ symbol: str | None = None,
+ aux: str | None = None,
+) -> IdMapOutput:
+ """Returns a mapping of all cryptocurrencies to unique CoinMarketCap IDs"""
+ if not api_key or not api_key.strip():
+ return IdMapOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+ params: dict[str, Any] = {"limit": limit}
+ if listing_status:
+ params["listing_status"] = listing_status
+ if start is not None:
+ params["start"] = start
+ if sort:
+ params["sort"] = sort
+ if symbol:
+ params["symbol"] = symbol
+ if aux:
+ params["aux"] = aux
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{_BASE_URL}/v1/cryptocurrency/map",
+ headers=_headers(api_key),
+ params=params,
+ )
+ if response.status_code != 200:
+ return IdMapOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ body = response.json()
+ except httpx.TimeoutException:
+ return IdMapOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return IdMapOutput(success=False, error=f"Call failed: {exc}")
+
+ raw_data = body.get("data", [])
+ data = [
+ CryptocurrencyMapItem(
+ id=item.get("id"),
+ name=item.get("name"),
+ symbol=item.get("symbol"),
+ slug=item.get("slug"),
+ is_active=item.get("is_active"),
+ first_historical_data=item.get("first_historical_data"),
+ last_historical_data=item.get("last_historical_data"),
+ )
+ for item in raw_data
+ if isinstance(item, dict)
+ ]
+ return IdMapOutput(success=True, data=data)
+
+
+@tool(args_schema=LatestListingsInput)
+@serialize_pydantic_return
+async def latest_listings(
+ api_key: str,
+ start: int | None = None,
+ limit: int | None = None,
+ volume_24h_min: float | None = None,
+ convert: str | None = None,
+ convert_id: str | None = None,
+ sort: str | None = None,
+ sort_dir: str | None = None,
+ cryptocurrency_type: str | None = None,
+ aux: str | None = None,
+) -> LatestListingsOutput:
+ """Returns a paginated list of all active cryptocurrencies with latest market data"""
+ if not api_key or not api_key.strip():
+ return LatestListingsOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+ params: dict[str, Any] = {}
+ if start is not None:
+ params["start"] = start
+ if limit is not None:
+ params["limit"] = limit
+ if volume_24h_min is not None:
+ params["volume_24h_min"] = volume_24h_min
+ if convert:
+ params["convert"] = convert
+ if convert_id:
+ params["convert_id"] = convert_id
+ if sort:
+ params["sort"] = sort
+ if sort_dir:
+ params["sort_dir"] = sort_dir
+ if cryptocurrency_type:
+ params["cryptocurrency_type"] = cryptocurrency_type
+ if aux:
+ params["aux"] = aux
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{_BASE_URL}/v1/cryptocurrency/listings/latest",
+ headers=_headers(api_key),
+ params=params,
+ )
+ if response.status_code != 200:
+ return LatestListingsOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ body = response.json()
+ except httpx.TimeoutException:
+ return LatestListingsOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return LatestListingsOutput(success=False, error=f"Call failed: {exc}")
+
+ raw_data = body.get("data", [])
+ data = [
+ ListingItem(
+ id=item.get("id"),
+ name=item.get("name"),
+ symbol=item.get("symbol"),
+ slug=item.get("slug"),
+ cmc_rank=item.get("cmc_rank"),
+ circulating_supply=item.get("circulating_supply"),
+ total_supply=item.get("total_supply"),
+ max_supply=item.get("max_supply"),
+ quote=_parse_quote(item.get("quote", {})),
+ )
+ for item in raw_data
+ if isinstance(item, dict)
+ ]
+ return LatestListingsOutput(success=True, data=data)
+
+
+@tool(args_schema=LatestQuotesInput)
+@serialize_pydantic_return
+async def latest_quotes(
+ api_key: str,
+ id: str | None = None,
+ slug: str | None = None,
+ symbol: str | None = None,
+ convert: str | None = None,
+ convert_id: str | None = None,
+) -> LatestQuotesOutput:
+ """Returns the latest market quote for one or more cryptocurrencies. At least one of id, slug, or symbol is required."""
+ if not api_key or not api_key.strip():
+ return LatestQuotesOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+ params: dict[str, Any] = {}
+ if id:
+ params["id"] = id
+ if slug:
+ params["slug"] = slug
+ if symbol:
+ params["symbol"] = symbol
+ if convert:
+ params["convert"] = convert
+ if convert_id:
+ params["convert_id"] = convert_id
+ if not params:
+ return LatestQuotesOutput(
+ success=False,
+ error="At least one of id, slug, or symbol is required.",
+ )
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{_BASE_URL}/v1/cryptocurrency/quotes/latest",
+ headers=_headers(api_key),
+ params=params,
+ )
+ if response.status_code != 200:
+ return LatestQuotesOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ body = response.json()
+ except httpx.TimeoutException:
+ return LatestQuotesOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return LatestQuotesOutput(success=False, error=f"Call failed: {exc}")
+
+ raw_data = body.get("data", {})
+ data: dict[str, CryptocurrencyQuote] = {}
+ for key, item in raw_data.items():
+ if isinstance(item, dict):
+ data[key] = CryptocurrencyQuote(
+ id=item.get("id"),
+ name=item.get("name"),
+ symbol=item.get("symbol"),
+ slug=item.get("slug"),
+ cmc_rank=item.get("cmc_rank"),
+ quote=_parse_quote(item.get("quote", {})),
+ )
+ return LatestQuotesOutput(success=True, data=data)
diff --git a/src/modulex_integrations/tools/fellow/README.md b/src/modulex_integrations/tools/fellow/README.md
new file mode 100644
index 0000000..eb6eb7d
--- /dev/null
+++ b/src/modulex_integrations/tools/fellow/README.md
@@ -0,0 +1,34 @@
+# Fellow
+
+Meeting productivity platform for notes, action items, and meeting management via the Fellow REST API (`https://.fellow.app/api/v1`).
+
+## Authentication
+
+### API Key Authentication
+
+- Sign in to your Fellow workspace at `https://.fellow.app`
+- Navigate to **Settings > Integrations > API** and generate or copy your API key
+- Note your workspace subdomain (the part before `.fellow.app` in your URL)
+- Required env vars:
+ - `FELLOW_SUBDOMAIN` (format: `mycompany`) — your workspace subdomain
+ - `FELLOW_API_KEY` (format: `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`) — your API key
+- Docs:
+
+## Tools
+
+| name | description | required params |
+| --- | --- | --- |
+| `archive_action_item` | Archive an action item | `action_item_id` |
+| `complete_action_item` | Complete an action item | `action_item_id` |
+| `get_note_by_id` | Get a note by its ID | `note_id` |
+
+Every tool takes additional `subdomain` and `api_key` parameters that the runtime fills in from the resolved credential.
+
+## Limits & Quotas
+
+- No documented public rate limits from Fellow's API documentation.
+- Error model: non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising.
+
+## Maintainer
+
+ModuleX core team.
diff --git a/src/modulex_integrations/tools/fellow/__init__.py b/src/modulex_integrations/tools/fellow/__init__.py
new file mode 100644
index 0000000..d6be28e
--- /dev/null
+++ b/src/modulex_integrations/tools/fellow/__init__.py
@@ -0,0 +1,21 @@
+"""Fellow integration — discovered via the ``modulex.tools`` entry point."""
+from modulex_integrations.tools.fellow.manifest import manifest
+from modulex_integrations.tools.fellow.tools import (
+ archive_action_item,
+ complete_action_item,
+ get_note_by_id,
+)
+
+TOOLS = (
+ archive_action_item,
+ complete_action_item,
+ get_note_by_id,
+)
+
+__all__ = [
+ "TOOLS",
+ "archive_action_item",
+ "complete_action_item",
+ "get_note_by_id",
+ "manifest",
+]
diff --git a/src/modulex_integrations/tools/fellow/dependencies.toml b/src/modulex_integrations/tools/fellow/dependencies.toml
new file mode 100644
index 0000000..7b49573
--- /dev/null
+++ b/src/modulex_integrations/tools/fellow/dependencies.toml
@@ -0,0 +1,3 @@
+# Runtime dependencies for the fellow integration.
+# CI assembles this into the root pyproject's [project.optional-dependencies].
+dependencies = []
diff --git a/src/modulex_integrations/tools/fellow/manifest.py b/src/modulex_integrations/tools/fellow/manifest.py
new file mode 100644
index 0000000..ce37050
--- /dev/null
+++ b/src/modulex_integrations/tools/fellow/manifest.py
@@ -0,0 +1,104 @@
+"""Fellow integration manifest."""
+from __future__ import annotations
+
+from modulex_integrations.schema import (
+ ActionDefinition,
+ ApiKeyAuthSchema,
+ EnvVar,
+ IntegrationManifest,
+ ParameterDef,
+ SuccessIndicators,
+ TestEndpoint,
+)
+
+__all__ = ["manifest"]
+
+
+manifest = IntegrationManifest(
+ name="fellow",
+ display_name="Fellow",
+ description="Meeting productivity platform for notes, action items, and meeting management",
+ version="1.0.0",
+ author="ModuleX",
+ logo="modulex:fellow-themed",
+ app_url="https://fellow.ai",
+ categories=["Productivity & Collaboration", "meetings"],
+ actions=[
+ ActionDefinition(
+ name="archive_action_item",
+ description="Archive an action item",
+ parameters={
+ "action_item_id": ParameterDef(
+ type="string",
+ description="The ID of the action item to archive",
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="complete_action_item",
+ description="Complete an action item",
+ parameters={
+ "action_item_id": ParameterDef(
+ type="string",
+ description="The ID of the action item to mark as complete",
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="get_note_by_id",
+ description="Get a note by its ID",
+ parameters={
+ "note_id": ParameterDef(
+ type="string",
+ description="The ID of the note to retrieve",
+ required=True,
+ ),
+ },
+ ),
+ ],
+ auth_schemas=[
+ ApiKeyAuthSchema(
+ display_name="API Key Authentication",
+ description="Authenticate using your Fellow API key and workspace subdomain",
+ setup_instructions=[
+ "Sign in to your Fellow workspace at https://.fellow.app",
+ "Navigate to Settings > Integrations > API",
+ "Generate a new API key or copy your existing one",
+ "Note your workspace subdomain (the part before .fellow.app in your URL)",
+ ],
+ setup_environment_variables=[
+ EnvVar(
+ name="FELLOW_SUBDOMAIN",
+ display_name="Workspace Subdomain",
+ description="Your Fellow workspace subdomain (the part before .fellow.app)",
+ required=True,
+ sensitive=False,
+ sample_format="mycompany",
+ about_url="https://fellow.app",
+ ),
+ EnvVar(
+ name="FELLOW_API_KEY",
+ display_name="API Key",
+ description="Your Fellow API key from Settings > Integrations > API",
+ required=True,
+ sensitive=True,
+ sample_format="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ about_url="https://developers.fellow.ai",
+ ),
+ ],
+ test_endpoint=TestEndpoint(
+ url="https://{subdomain}.fellow.app/api/v1/note",
+ method="GET",
+ headers={"x-api-key": "{api_key}"},
+ params={"limit": "1"},
+ success_indicators=SuccessIndicators(
+ status_codes=[200],
+ ),
+ cost_level="free",
+ description="Validates credentials by listing notes with limit=1",
+ ),
+ ),
+ ],
+)
diff --git a/src/modulex_integrations/tools/fellow/outputs.py b/src/modulex_integrations/tools/fellow/outputs.py
new file mode 100644
index 0000000..19885eb
--- /dev/null
+++ b/src/modulex_integrations/tools/fellow/outputs.py
@@ -0,0 +1,36 @@
+"""Pydantic response models for the fellow integration's @tool functions."""
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict
+
+__all__ = [
+ "ArchiveActionItemOutput",
+ "CompleteActionItemOutput",
+ "GetNoteByIdOutput",
+]
+
+
+class _Base(BaseModel):
+ """Shared config for every output model in this integration."""
+
+ model_config = ConfigDict(extra="forbid")
+
+
+class ArchiveActionItemOutput(_Base):
+ success: bool
+ error: str | None = None
+ data: dict[str, Any] | None = None
+
+
+class CompleteActionItemOutput(_Base):
+ success: bool
+ error: str | None = None
+ data: dict[str, Any] | None = None
+
+
+class GetNoteByIdOutput(_Base):
+ success: bool
+ error: str | None = None
+ data: dict[str, Any] | None = None
diff --git a/src/modulex_integrations/tools/fellow/tests/__init__.py b/src/modulex_integrations/tools/fellow/tests/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/modulex_integrations/tools/fellow/tests/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/modulex_integrations/tools/fellow/tests/test_fellow.py b/src/modulex_integrations/tools/fellow/tests/test_fellow.py
new file mode 100644
index 0000000..9499234
--- /dev/null
+++ b/src/modulex_integrations/tools/fellow/tests/test_fellow.py
@@ -0,0 +1,109 @@
+"""Happy-path tests for every fellow @tool, plus a manifest sanity check."""
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from modulex_integrations.tools.fellow import (
+ TOOLS,
+ archive_action_item,
+ complete_action_item,
+ get_note_by_id,
+ manifest,
+)
+from modulex_integrations.tools.fellow.outputs import (
+ ArchiveActionItemOutput,
+ CompleteActionItemOutput,
+ GetNoteByIdOutput,
+)
+
+_SUBDOMAIN = "testworkspace"
+_API_KEY = "fake-api-key"
+
+API = f"https://{_SUBDOMAIN}.fellow.app/api/v1"
+
+
+def _args(**extra: Any) -> dict[str, Any]:
+ return dict(subdomain=_SUBDOMAIN, api_key=_API_KEY, **extra)
+
+
+# --- Manifest sanity --------------------------------------------------------
+
+
+class TestManifest:
+ def test_manifest_exposes_3_actions(self) -> None:
+ assert len(manifest.actions) == 3
+
+ def test_manifest_actions_match_tools_tuple(self) -> None:
+ assert {a.name for a in manifest.actions} == {t.name for t in TOOLS}
+
+ def test_manifest_has_api_key_auth(self) -> None:
+ assert {a.auth_type for a in manifest.auth_schemas} == {"api_key"}
+
+
+# --- Per-action happy-path tests -------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_archive_action_item(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/action_item/123/archive",
+ json={
+ # TODO: fill in a representative response shape from the upstream API docs
+ },
+ )
+
+ result_dict = await archive_action_item.ainvoke(_args(action_item_id="123"))
+
+ assert isinstance(result_dict, dict)
+ result = ArchiveActionItemOutput.model_validate(result_dict)
+ assert result.success is True
+
+
+@pytest.mark.asyncio
+async def test_complete_action_item(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/action_item/456/complete",
+ json={
+ # TODO: fill in a representative response shape from the upstream API docs
+ },
+ )
+
+ result_dict = await complete_action_item.ainvoke(_args(action_item_id="456"))
+
+ assert isinstance(result_dict, dict)
+ result = CompleteActionItemOutput.model_validate(result_dict)
+ assert result.success is True
+
+
+@pytest.mark.asyncio
+async def test_get_note_by_id(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/note/789",
+ json={
+ # TODO: fill in a representative response shape from the upstream API docs
+ },
+ )
+
+ result_dict = await get_note_by_id.ainvoke(_args(note_id="789"))
+
+ assert isinstance(result_dict, dict)
+ result = GetNoteByIdOutput.model_validate(result_dict)
+ assert result.success is True
+
+
+# --- Failure-path tests ---------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_archive_action_item_validates_empty_api_key() -> None:
+ result_dict = await archive_action_item.ainvoke(
+ {"action_item_id": "123", "subdomain": "test", "api_key": ""}
+ )
+ result = ArchiveActionItemOutput.model_validate(result_dict)
+ assert result.success is False
+ assert "API key" in (result.error or "")
diff --git a/src/modulex_integrations/tools/fellow/tools.py b/src/modulex_integrations/tools/fellow/tools.py
new file mode 100644
index 0000000..d06045d
--- /dev/null
+++ b/src/modulex_integrations/tools/fellow/tools.py
@@ -0,0 +1,171 @@
+"""Fellow LangChain @tool functions."""
+from __future__ import annotations
+
+import httpx
+from langchain_core.tools import tool
+from pydantic import BaseModel, Field
+
+from modulex_integrations import serialize_pydantic_return
+from modulex_integrations.tools.fellow.outputs import (
+ ArchiveActionItemOutput,
+ CompleteActionItemOutput,
+ GetNoteByIdOutput,
+)
+
+__all__ = [
+ "archive_action_item",
+ "complete_action_item",
+ "get_note_by_id",
+]
+
+_TIMEOUT = 30.0
+
+
+def _base_url(subdomain: str) -> str:
+ return f"https://{subdomain}.fellow.app/api/v1"
+
+
+def _headers(api_key: str) -> dict[str, str]:
+ return {
+ "x-api-key": api_key,
+ "Content-Type": "application/json",
+ }
+
+
+# --- Input schemas --------------------------------------------------------
+
+
+class ArchiveActionItemInput(BaseModel):
+ action_item_id: str = Field(description="The ID of the action item to archive")
+ subdomain: str = Field(description="Fellow workspace subdomain")
+ api_key: str = Field(description="Fellow API key")
+
+
+class CompleteActionItemInput(BaseModel):
+ action_item_id: str = Field(description="The ID of the action item to mark as complete")
+ subdomain: str = Field(description="Fellow workspace subdomain")
+ api_key: str = Field(description="Fellow API key")
+
+
+class GetNoteByIdInput(BaseModel):
+ note_id: str = Field(description="The ID of the note to retrieve")
+ subdomain: str = Field(description="Fellow workspace subdomain")
+ api_key: str = Field(description="Fellow API key")
+
+
+# --- @tool functions ------------------------------------------------------
+
+
+@tool(args_schema=ArchiveActionItemInput)
+@serialize_pydantic_return
+async def archive_action_item(
+ action_item_id: str,
+ subdomain: str,
+ api_key: str,
+) -> ArchiveActionItemOutput:
+ """Archive an action item."""
+ if not api_key or not api_key.strip():
+ return ArchiveActionItemOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+ if not subdomain or not subdomain.strip():
+ return ArchiveActionItemOutput(
+ success=False,
+ error="Subdomain is empty. Please configure your Fellow workspace subdomain.",
+ )
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.post(
+ f"{_base_url(subdomain)}/action_item/{action_item_id}/archive",
+ headers=_headers(api_key),
+ )
+ if response.status_code != 200:
+ return ArchiveActionItemOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return ArchiveActionItemOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return ArchiveActionItemOutput(success=False, error=f"Call failed: {exc}")
+
+ return ArchiveActionItemOutput(success=True, data=data)
+
+
+@tool(args_schema=CompleteActionItemInput)
+@serialize_pydantic_return
+async def complete_action_item(
+ action_item_id: str,
+ subdomain: str,
+ api_key: str,
+) -> CompleteActionItemOutput:
+ """Complete an action item."""
+ if not api_key or not api_key.strip():
+ return CompleteActionItemOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+ if not subdomain or not subdomain.strip():
+ return CompleteActionItemOutput(
+ success=False,
+ error="Subdomain is empty. Please configure your Fellow workspace subdomain.",
+ )
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.post(
+ f"{_base_url(subdomain)}/action_item/{action_item_id}/complete",
+ headers=_headers(api_key),
+ json={"completed": True},
+ )
+ if response.status_code != 200:
+ return CompleteActionItemOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return CompleteActionItemOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return CompleteActionItemOutput(success=False, error=f"Call failed: {exc}")
+
+ return CompleteActionItemOutput(success=True, data=data)
+
+
+@tool(args_schema=GetNoteByIdInput)
+@serialize_pydantic_return
+async def get_note_by_id(
+ note_id: str,
+ subdomain: str,
+ api_key: str,
+) -> GetNoteByIdOutput:
+ """Get a note by its ID."""
+ if not api_key or not api_key.strip():
+ return GetNoteByIdOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+ if not subdomain or not subdomain.strip():
+ return GetNoteByIdOutput(
+ success=False,
+ error="Subdomain is empty. Please configure your Fellow workspace subdomain.",
+ )
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{_base_url(subdomain)}/note/{note_id}",
+ headers=_headers(api_key),
+ )
+ if response.status_code != 200:
+ return GetNoteByIdOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return GetNoteByIdOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return GetNoteByIdOutput(success=False, error=f"Call failed: {exc}")
+
+ return GetNoteByIdOutput(success=True, data=data)
diff --git a/src/modulex_integrations/tools/gong/README.md b/src/modulex_integrations/tools/gong/README.md
new file mode 100644
index 0000000..8938bd0
--- /dev/null
+++ b/src/modulex_integrations/tools/gong/README.md
@@ -0,0 +1,34 @@
+# Gong
+
+Revenue intelligence platform for recording, transcribing, and analyzing sales conversations via the Gong REST API (`us-66463.api.gong.io/v2`).
+
+## Authentication
+
+### OAuth2 Authentication (recommended)
+
+- Register an OAuth app at .
+- Redirect URI: `https://api.modulex.dev/credentials/oauth2/callback`
+- Required scopes: `api:calls:read:basic`, `api:calls:read:extensive`, `api:calls:create`, `api:workspaces:read`, `api:calls:read:transcript`
+- Env vars (custom app only): `GONG_OAUTH2_CLIENT_ID`, `GONG_OAUTH2_CLIENT_SECRET`
+
+## Tools
+
+| name | description | required params |
+| --- | --- | --- |
+| `add_new_call` | Add a new call to Gong | `client_unique_id`, `actual_start`, `direction`, `primary_user`, `parties` |
+| `get_extensive_data` | List detailed call data with content selectors for topics, trackers, transcripts, and more | (none required) |
+| `list_calls` | List calls with optional date range filtering | (none required) |
+| `list_workspace_id_options` | Retrieve available workspace IDs and names | (none required) |
+| `retrieve_transcripts_of_calls` | Retrieve transcripts of calls with optional date range and call ID filtering | (none required) |
+
+Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fills in from the resolved OAuth credential.
+
+## Limits & Quotas
+
+- Gong API rate limits vary by endpoint and plan tier. Consult your Gong admin for specific limits.
+- The `get_extensive_data` action paginates internally up to `max_results` (default 600).
+- Error model: non-2xx responses are caught and returned as `success=False` + `error` rather than raising.
+
+## Maintainer
+
+ModuleX core team.
diff --git a/src/modulex_integrations/tools/gong/__init__.py b/src/modulex_integrations/tools/gong/__init__.py
new file mode 100644
index 0000000..edc6981
--- /dev/null
+++ b/src/modulex_integrations/tools/gong/__init__.py
@@ -0,0 +1,27 @@
+"""Gong integration — discovered via the ``modulex.tools`` entry point."""
+from modulex_integrations.tools.gong.manifest import manifest
+from modulex_integrations.tools.gong.tools import (
+ add_new_call,
+ get_extensive_data,
+ list_calls,
+ list_workspace_id_options,
+ retrieve_transcripts_of_calls,
+)
+
+TOOLS = (
+ add_new_call,
+ get_extensive_data,
+ list_calls,
+ list_workspace_id_options,
+ retrieve_transcripts_of_calls,
+)
+
+__all__ = [
+ "TOOLS",
+ "add_new_call",
+ "get_extensive_data",
+ "list_calls",
+ "list_workspace_id_options",
+ "manifest",
+ "retrieve_transcripts_of_calls",
+]
diff --git a/src/modulex_integrations/tools/gong/dependencies.toml b/src/modulex_integrations/tools/gong/dependencies.toml
new file mode 100644
index 0000000..f7224f8
--- /dev/null
+++ b/src/modulex_integrations/tools/gong/dependencies.toml
@@ -0,0 +1,3 @@
+# Runtime dependencies for the gong integration.
+# CI assembles this into the root pyproject's [project.optional-dependencies].
+dependencies = []
diff --git a/src/modulex_integrations/tools/gong/manifest.py b/src/modulex_integrations/tools/gong/manifest.py
new file mode 100644
index 0000000..2f61526
--- /dev/null
+++ b/src/modulex_integrations/tools/gong/manifest.py
@@ -0,0 +1,264 @@
+"""Gong integration manifest."""
+from __future__ import annotations
+
+from modulex_integrations.schema import (
+ ActionDefinition,
+ EnvVar,
+ IntegrationManifest,
+ OAuth2AuthSchema,
+ OAuthConfig,
+ ParameterDef,
+ SuccessIndicators,
+ TestEndpoint,
+)
+
+__all__ = ["manifest"]
+
+
+manifest = IntegrationManifest(
+ name="gong",
+ display_name="Gong",
+ description="Revenue intelligence platform for recording, transcribing, and analyzing sales conversations",
+ version="1.0.0",
+ author="ModuleX",
+ logo="modulex:gong",
+ app_url="https://www.gong.io",
+ categories=["Sales", "Revenue Intelligence", "Conversation Analytics"],
+ actions=[
+ ActionDefinition(
+ name="add_new_call",
+ description="Add a new call to Gong",
+ parameters={
+ "client_unique_id": ParameterDef(
+ type="string",
+ description="A call's unique identifier in the PBX or recording system. Used to prevent duplicate uploads.",
+ required=True,
+ ),
+ "actual_start": ParameterDef(
+ type="string",
+ description="The actual date and time when the call started in ISO-8601 format (e.g., 2018-02-18T02:30:00-07:00 or 2018-02-18T08:00:00Z).",
+ required=True,
+ ),
+ "direction": ParameterDef(
+ type="string",
+ description="Whether the call is Inbound, Outbound, Conference, or Unknown.",
+ required=True,
+ ),
+ "primary_user": ParameterDef(
+ type="string",
+ description="The Gong internal user ID of the team member who hosted the call.",
+ required=True,
+ ),
+ "parties": ParameterDef(
+ type="array",
+ description="A list of the call's participants as JSON objects. Each party can have: phoneNumber, emailAddress, name, mediaChannelId.",
+ required=True,
+ ),
+ "title": ParameterDef(
+ type="string",
+ description="The title of the call, available for indexing and search.",
+ ),
+ "purpose": ParameterDef(
+ type="string",
+ description="The purpose of the call. Free text up to 255 characters.",
+ ),
+ "scheduled_start": ParameterDef(
+ type="string",
+ description="The date and time the call was scheduled to begin in ISO-8601 format.",
+ ),
+ "scheduled_end": ParameterDef(
+ type="string",
+ description="The date and time the call was scheduled to end in ISO-8601 format.",
+ ),
+ "duration": ParameterDef(
+ type="integer",
+ description="The actual call duration in seconds.",
+ ),
+ "disposition": ParameterDef(
+ type="string",
+ description="The disposition of the call. Free text up to 255 characters.",
+ ),
+ "meeting_url": ParameterDef(
+ type="string",
+ description="The URL of the conference call by which users join the meeting.",
+ ),
+ "call_provider_code": ParameterDef(
+ type="string",
+ description="Code identifying the conferencing/telephony system: zoom, clearslide, gotomeeting, ringcentral, outreach, insidesales.",
+ ),
+ "download_media_url": ParameterDef(
+ type="string",
+ description="The URL from which Gong can download the media file. Must be unique, max 1.5GB.",
+ ),
+ "workspace_id": ParameterDef(
+ type="string",
+ description="Optional workspace identifier for call placement.",
+ ),
+ "language_code": ParameterDef(
+ type="string",
+ description="Language code for transcription (e.g., en-US, fr-FR). Optional; Gong auto-detects if not set.",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="get_extensive_data",
+ description="List detailed call data with content selectors for topics, trackers, transcripts, and more",
+ parameters={
+ "from_date_time": ParameterDef(
+ type="string",
+ description="Date and time (ISO-8601) from which to list recorded calls.",
+ ),
+ "to_date_time": ParameterDef(
+ type="string",
+ description="Date and time (ISO-8601) until which to list recorded calls.",
+ ),
+ "workspace_id": ParameterDef(
+ type="string",
+ description="The ID of the workspace to filter by.",
+ ),
+ "call_ids": ParameterDef(
+ type="array",
+ description="List of call ID strings to filter. If not supplied, returns all calls in date range.",
+ ),
+ "primary_user_ids": ParameterDef(
+ type="array",
+ description="List of user ID strings. If supplied, returns only calls hosted by these users.",
+ ),
+ "max_results": ParameterDef(
+ type="integer",
+ description="Maximum number of results to return.",
+ default=600,
+ ),
+ "context": ParameterDef(
+ type="string",
+ description="Context level: None, Basic (add links), or Extended (include link data).",
+ default="None",
+ ),
+ "context_timing": ParameterDef(
+ type="array",
+ description="Timing for context data: 'Now' or 'TimeOfCall' or both. Only valid when context is Extended.",
+ ),
+ "include_parties": ParameterDef(
+ type="boolean",
+ description="Whether to include parties in the response.",
+ default=False,
+ ),
+ "exposed_fields_content": ParameterDef(
+ type="object",
+ description="Fields to include for content: structure, topics, trackers, trackerOccurrences, pointsOfInterest, brief, outline, highlights, callOutcome, keyPoints (boolean values).",
+ ),
+ "exposed_fields_interaction": ParameterDef(
+ type="object",
+ description="Fields to include for interaction: speakers, video, personInteractionStats, questions (boolean values).",
+ ),
+ "include_public_comments": ParameterDef(
+ type="boolean",
+ description="Whether to include public comments in the response.",
+ default=False,
+ ),
+ "include_media": ParameterDef(
+ type="boolean",
+ description="Whether to include media in the response.",
+ default=False,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="list_calls",
+ description="List calls with optional date range filtering",
+ parameters={
+ "from_date_time": ParameterDef(
+ type="string",
+ description="Date and time (ISO-8601) from which to list recorded calls.",
+ ),
+ "to_date_time": ParameterDef(
+ type="string",
+ description="Date and time (ISO-8601) until which to list recorded calls.",
+ ),
+ "cursor": ParameterDef(
+ type="string",
+ description="Pagination cursor returned by a previous call.",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="list_workspace_id_options",
+ description="Retrieve available workspace IDs and names",
+ parameters={},
+ ),
+ ActionDefinition(
+ name="retrieve_transcripts_of_calls",
+ description="Retrieve transcripts of calls with optional date range and call ID filtering",
+ parameters={
+ "from_date_time": ParameterDef(
+ type="string",
+ description="Date and time (ISO-8601) from which to filter calls.",
+ ),
+ "to_date_time": ParameterDef(
+ type="string",
+ description="Date and time (ISO-8601) until which to filter calls.",
+ ),
+ "workspace_id": ParameterDef(
+ type="string",
+ description="The ID of the workspace to filter by.",
+ ),
+ "call_ids": ParameterDef(
+ type="array",
+ description="List of call ID strings to retrieve transcripts for.",
+ ),
+ },
+ ),
+ ],
+ auth_schemas=[
+ OAuth2AuthSchema(
+ display_name="OAuth2 Authentication",
+ description="Connect using Gong OAuth (recommended)",
+ setup_environment_variables=[
+ EnvVar(
+ name="GONG_OAUTH2_CLIENT_ID",
+ display_name="Client ID",
+ description="Gong OAuth App Client ID",
+ required=True,
+ sensitive=False,
+ only_for_custom=True,
+ sample_format="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
+ about_url="https://app.gong.io/company/api",
+ ),
+ EnvVar(
+ name="GONG_OAUTH2_CLIENT_SECRET",
+ display_name="Client Secret",
+ description="Gong OAuth App Client Secret",
+ required=True,
+ sensitive=True,
+ only_for_custom=True,
+ sample_format="x" * 40,
+ about_url="https://app.gong.io/company/api",
+ ),
+ ],
+ oauth_config=OAuthConfig(
+ auth_url="https://app.gong.io/oauth2/authorize",
+ token_url="https://app.gong.io/oauth2/generate-customer-token",
+ scopes=[
+ "api:calls:read:basic",
+ "api:calls:read:extensive",
+ "api:calls:create",
+ "api:workspaces:read",
+ "api:calls:read:transcript",
+ ],
+ ),
+ test_endpoint=TestEndpoint(
+ url="https://us-66463.api.gong.io/v2/calls",
+ method="GET",
+ headers={
+ "Authorization": "Bearer {access_token}",
+ },
+ success_indicators=SuccessIndicators(
+ status_codes=[200],
+ response_fields=["requestId"],
+ ),
+ cost_level="free",
+ description="Validates OAuth token by listing calls",
+ ),
+ ),
+ ],
+)
diff --git a/src/modulex_integrations/tools/gong/outputs.py b/src/modulex_integrations/tools/gong/outputs.py
new file mode 100644
index 0000000..e73aaf7
--- /dev/null
+++ b/src/modulex_integrations/tools/gong/outputs.py
@@ -0,0 +1,67 @@
+"""Pydantic response models for the gong integration's @tool functions."""
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field
+
+__all__ = [
+ "AddNewCallOutput",
+ "GetExtensiveDataOutput",
+ "ListCallsOutput",
+ "ListWorkspaceIdOptionsOutput",
+ "RetrieveTranscriptsOfCallsOutput",
+ "Workspace",
+]
+
+
+class _Base(BaseModel):
+ """Shared config for every output model in this integration."""
+
+ model_config = ConfigDict(extra="forbid")
+
+
+# --- Nested resource models -----------------------------------------------
+
+
+class Workspace(_Base):
+ """A Gong workspace entry."""
+
+ id: str | None = None
+ name: str | None = None
+
+
+# --- Per-action output models ---------------------------------------------
+
+
+class AddNewCallOutput(_Base):
+ success: bool
+ error: str | None = None
+ request_id: str | None = None
+ call_id: str | None = None
+
+
+class GetExtensiveDataOutput(_Base):
+ success: bool
+ error: str | None = None
+ calls: list[dict[str, Any]] = Field(default_factory=list)
+
+
+class ListCallsOutput(_Base):
+ success: bool
+ error: str | None = None
+ request_id: str | None = None
+ cursor: str | None = None
+ calls: list[dict[str, Any]] = Field(default_factory=list)
+
+
+class ListWorkspaceIdOptionsOutput(_Base):
+ success: bool
+ error: str | None = None
+ workspaces: list[Workspace] = Field(default_factory=list)
+
+
+class RetrieveTranscriptsOfCallsOutput(_Base):
+ success: bool
+ error: str | None = None
+ call_transcripts: list[dict[str, Any]] = Field(default_factory=list)
diff --git a/src/modulex_integrations/tools/gong/tests/__init__.py b/src/modulex_integrations/tools/gong/tests/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/modulex_integrations/tools/gong/tests/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/modulex_integrations/tools/gong/tests/test_gong.py b/src/modulex_integrations/tools/gong/tests/test_gong.py
new file mode 100644
index 0000000..9a1af1a
--- /dev/null
+++ b/src/modulex_integrations/tools/gong/tests/test_gong.py
@@ -0,0 +1,197 @@
+"""Happy-path tests for every gong @tool, plus a manifest sanity check."""
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from modulex_integrations.tools.gong import (
+ TOOLS,
+ add_new_call,
+ get_extensive_data,
+ list_calls,
+ list_workspace_id_options,
+ manifest,
+ retrieve_transcripts_of_calls,
+)
+from modulex_integrations.tools.gong.outputs import (
+ AddNewCallOutput,
+ GetExtensiveDataOutput,
+ ListCallsOutput,
+ ListWorkspaceIdOptionsOutput,
+ RetrieveTranscriptsOfCallsOutput,
+)
+
+API = "https://us-66463.api.gong.io/v2"
+
+_AUTH: dict[str, Any] = {
+ "auth_type": "oauth2",
+ "auth_data": {"access_token": "fake_access_token"},
+}
+
+
+def _args(**extra: Any) -> dict[str, Any]:
+ """Build a .ainvoke() input dict: auth + per-test extras."""
+ return dict(_AUTH, **extra)
+
+
+# --- Manifest sanity --------------------------------------------------------
+
+
+class TestManifest:
+ def test_manifest_exposes_5_actions(self) -> None:
+ assert len(manifest.actions) == 5
+
+ def test_manifest_actions_match_tools_tuple(self) -> None:
+ assert {a.name for a in manifest.actions} == {t.name for t in TOOLS}
+
+ def test_manifest_has_oauth2_auth(self) -> None:
+ assert {a.auth_type for a in manifest.auth_schemas} == {"oauth2"}
+
+
+# --- Per-action happy-path tests -------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_add_new_call(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/calls",
+ json={
+ # TODO: fill in a representative response shape from the Gong API docs
+ "requestId": "req-123",
+ "callId": "call-456",
+ },
+ )
+
+ result_dict = await add_new_call.ainvoke(
+ _args(
+ client_unique_id="unique-id-123",
+ actual_start="2024-01-15T10:00:00Z",
+ direction="Outbound",
+ primary_user="user-789",
+ parties=[{"emailAddress": "test@example.com", "name": "Test User"}],
+ )
+ )
+
+ assert isinstance(result_dict, dict)
+ result = AddNewCallOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.request_id == "req-123"
+ assert result.call_id == "call-456"
+
+
+@pytest.mark.asyncio
+async def test_get_extensive_data(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/calls/extensive",
+ json={
+ # TODO: fill in a representative response shape from the Gong API docs
+ "requestId": "req-abc",
+ "records": {"cursor": None, "totalRecords": 1},
+ "calls": [{"metaData": {"id": "call-1", "title": "Demo Call"}}],
+ },
+ )
+
+ result_dict = await get_extensive_data.ainvoke(
+ _args(from_date_time="2024-01-01T00:00:00Z", to_date_time="2024-01-31T23:59:59Z")
+ )
+
+ assert isinstance(result_dict, dict)
+ result = GetExtensiveDataOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.calls) == 1
+
+
+@pytest.mark.asyncio
+async def test_list_calls(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/calls",
+ json={
+ # TODO: fill in a representative response shape from the Gong API docs
+ "requestId": "req-xyz",
+ "records": {"cursor": "next-page", "totalRecords": 2},
+ "calls": [
+ {"id": "call-1", "title": "Call A"},
+ {"id": "call-2", "title": "Call B"},
+ ],
+ },
+ )
+
+ result_dict = await list_calls.ainvoke(_args())
+
+ assert isinstance(result_dict, dict)
+ result = ListCallsOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.calls) == 2
+ assert result.request_id == "req-xyz"
+
+
+@pytest.mark.asyncio
+async def test_list_workspace_id_options(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/workspaces",
+ json={
+ # TODO: fill in a representative response shape from the Gong API docs
+ "workspaces": [
+ {"id": "ws-1", "name": "Default Workspace"},
+ {"id": "ws-2", "name": "Sales Team"},
+ ],
+ },
+ )
+
+ result_dict = await list_workspace_id_options.ainvoke(_args())
+
+ assert isinstance(result_dict, dict)
+ result = ListWorkspaceIdOptionsOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.workspaces) == 2
+ assert result.workspaces[0].name == "Default Workspace"
+
+
+@pytest.mark.asyncio
+async def test_retrieve_transcripts_of_calls(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/calls/transcript",
+ json={
+ # TODO: fill in a representative response shape from the Gong API docs
+ "requestId": "req-tr-1",
+ "callTranscripts": [
+ {
+ "callId": "call-1",
+ "transcript": [
+ {"speakerId": "s1", "topic": "Introduction", "sentences": []}
+ ],
+ }
+ ],
+ },
+ )
+
+ result_dict = await retrieve_transcripts_of_calls.ainvoke(
+ _args(call_ids=["call-1"])
+ )
+
+ assert isinstance(result_dict, dict)
+ result = RetrieveTranscriptsOfCallsOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.call_transcripts) == 1
+
+
+# --- Failure-path tests (credential short-circuit) -------------------------
+
+
+@pytest.mark.asyncio
+async def test_list_calls_empty_credential() -> None:
+ """list_calls must fail immediately when access_token is missing/empty."""
+ result_dict = await list_calls.ainvoke(
+ {"auth_type": "oauth2", "auth_data": {"access_token": ""}}
+ )
+
+ assert isinstance(result_dict, dict)
+ result = ListCallsOutput.model_validate(result_dict)
+ assert result.success is False
+ assert "access_token" in (result.error or "")
diff --git a/src/modulex_integrations/tools/gong/tools.py b/src/modulex_integrations/tools/gong/tools.py
new file mode 100644
index 0000000..bc1a6ea
--- /dev/null
+++ b/src/modulex_integrations/tools/gong/tools.py
@@ -0,0 +1,426 @@
+"""Gong LangChain @tool functions."""
+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.gong.outputs import (
+ AddNewCallOutput,
+ GetExtensiveDataOutput,
+ ListCallsOutput,
+ ListWorkspaceIdOptionsOutput,
+ RetrieveTranscriptsOfCallsOutput,
+ Workspace,
+)
+
+__all__ = [
+ "add_new_call",
+ "get_extensive_data",
+ "list_calls",
+ "list_workspace_id_options",
+ "retrieve_transcripts_of_calls",
+]
+
+_BASE_URL = "https://us-66463.api.gong.io/v2"
+_TIMEOUT = 30.0
+
+
+def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]:
+ """Build headers for the Gong API based on auth_type/auth_data."""
+ headers: dict[str, str] = {"Accept": "application/json"}
+ if auth_type == "oauth2":
+ access_token = auth_data.get("access_token")
+ if access_token:
+ headers["Authorization"] = f"Bearer {access_token}"
+ return headers
+
+
+# --- Input schemas --------------------------------------------------------
+
+
+class AddNewCallInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ client_unique_id: str = Field(description="A call's unique identifier in the PBX or recording system")
+ actual_start: str = Field(description="Actual start date/time in ISO-8601 format")
+ direction: str = Field(description="Call direction: Inbound, Outbound, Conference, or Unknown")
+ primary_user: str = Field(description="Gong internal user ID of the call host")
+ parties: list[dict[str, Any]] = Field(description="List of call participants as objects with phoneNumber, emailAddress, name, mediaChannelId")
+ title: str | None = Field(default=None, description="Title of the call")
+ purpose: str | None = Field(default=None, description="Purpose of the call, up to 255 characters")
+ scheduled_start: str | None = Field(default=None, description="Scheduled start in ISO-8601 format")
+ scheduled_end: str | None = Field(default=None, description="Scheduled end in ISO-8601 format")
+ duration: int | None = Field(default=None, description="Actual call duration in seconds")
+ disposition: str | None = Field(default=None, description="Disposition of the call, up to 255 characters")
+ meeting_url: str | None = Field(default=None, description="Conference call URL")
+ call_provider_code: str | None = Field(default=None, description="Provider code: zoom, clearslide, gotomeeting, ringcentral, outreach, insidesales")
+ download_media_url: str | None = Field(default=None, description="URL for Gong to download the media file")
+ workspace_id: str | None = Field(default=None, description="Workspace identifier for call placement")
+ language_code: str | None = Field(default=None, description="Language code for transcription (e.g., en-US)")
+
+
+class GetExtensiveDataInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ from_date_time: str | None = Field(default=None, description="Start date/time in ISO-8601 format")
+ to_date_time: str | None = Field(default=None, description="End date/time in ISO-8601 format")
+ workspace_id: str | None = Field(default=None, description="Workspace ID to filter by")
+ call_ids: list[str] | None = Field(default=None, description="List of call IDs to filter")
+ primary_user_ids: list[str] | None = Field(default=None, description="List of user IDs to filter by host")
+ max_results: int = Field(default=600, description="Maximum number of results to return")
+ context: str = Field(default="None", description="Context level: None, Basic, or Extended")
+ context_timing: list[str] | None = Field(default=None, description="Timing for context data: Now or TimeOfCall")
+ include_parties: bool = Field(default=False, description="Whether to include parties")
+ exposed_fields_content: dict[str, bool] | None = Field(default=None, description="Content fields to include")
+ exposed_fields_interaction: dict[str, bool] | None = Field(default=None, description="Interaction fields to include")
+ include_public_comments: bool = Field(default=False, description="Whether to include public comments")
+ include_media: bool = Field(default=False, description="Whether to include media")
+
+
+class ListCallsInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ from_date_time: str | None = Field(default=None, description="Start date/time in ISO-8601 format")
+ to_date_time: str | None = Field(default=None, description="End date/time in ISO-8601 format")
+ cursor: str | None = Field(default=None, description="Pagination cursor from a previous call")
+
+
+class ListWorkspaceIdOptionsInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+
+
+class RetrieveTranscriptsOfCallsInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ from_date_time: str | None = Field(default=None, description="Start date/time in ISO-8601 format")
+ to_date_time: str | None = Field(default=None, description="End date/time in ISO-8601 format")
+ workspace_id: str | None = Field(default=None, description="Workspace ID to filter by")
+ call_ids: list[str] | None = Field(default=None, description="List of call IDs to retrieve transcripts for")
+
+
+# --- @tool functions ------------------------------------------------------
+
+
+@tool(args_schema=AddNewCallInput)
+@serialize_pydantic_return
+async def add_new_call(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ client_unique_id: str,
+ actual_start: str,
+ direction: str,
+ primary_user: str,
+ parties: list[dict[str, Any]],
+ title: str | None = None,
+ purpose: str | None = None,
+ scheduled_start: str | None = None,
+ scheduled_end: str | None = None,
+ duration: int | None = None,
+ disposition: str | None = None,
+ meeting_url: str | None = None,
+ call_provider_code: str | None = None,
+ download_media_url: str | None = None,
+ workspace_id: str | None = None,
+ language_code: str | None = None,
+) -> AddNewCallOutput:
+ """Add a new call to Gong."""
+ if not auth_data.get("access_token"):
+ return AddNewCallOutput(success=False, error="Missing or empty access_token in auth_data.")
+ headers = _get_auth_headers(auth_type, auth_data)
+ headers["Content-Type"] = "application/json"
+
+ payload: dict[str, Any] = {
+ "clientUniqueId": client_unique_id,
+ "actualStart": actual_start,
+ "direction": direction,
+ "primaryUser": primary_user,
+ "parties": parties,
+ }
+ if title is not None:
+ payload["title"] = title
+ if purpose is not None:
+ payload["purpose"] = purpose
+ if scheduled_start is not None:
+ payload["scheduledStart"] = scheduled_start
+ if scheduled_end is not None:
+ payload["scheduledEnd"] = scheduled_end
+ if duration is not None:
+ payload["duration"] = duration
+ if disposition is not None:
+ payload["disposition"] = disposition
+ if meeting_url is not None:
+ payload["meetingUrl"] = meeting_url
+ if call_provider_code is not None:
+ payload["callProviderCode"] = call_provider_code
+ if download_media_url is not None:
+ payload["downloadMediaUrl"] = download_media_url
+ if workspace_id is not None:
+ payload["workspaceId"] = workspace_id
+ if language_code is not None:
+ payload["languageCode"] = language_code
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.post(
+ f"{_BASE_URL}/calls",
+ headers=headers,
+ json=payload,
+ )
+ if response.status_code not in (200, 201):
+ return AddNewCallOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return AddNewCallOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return AddNewCallOutput(success=False, error=f"Call failed: {exc}")
+
+ return AddNewCallOutput(
+ success=True,
+ request_id=data.get("requestId"),
+ call_id=data.get("callId"),
+ )
+
+
+@tool(args_schema=GetExtensiveDataInput)
+@serialize_pydantic_return
+async def get_extensive_data(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ from_date_time: str | None = None,
+ to_date_time: str | None = None,
+ workspace_id: str | None = None,
+ call_ids: list[str] | None = None,
+ primary_user_ids: list[str] | None = None,
+ max_results: int = 600,
+ context: str = "None",
+ context_timing: list[str] | None = None,
+ include_parties: bool = False,
+ exposed_fields_content: dict[str, bool] | None = None,
+ exposed_fields_interaction: dict[str, bool] | None = None,
+ include_public_comments: bool = False,
+ include_media: bool = False,
+) -> GetExtensiveDataOutput:
+ """List detailed call data with content selectors for topics, trackers, transcripts, and more."""
+ if not auth_data.get("access_token"):
+ return GetExtensiveDataOutput(success=False, error="Missing or empty access_token in auth_data.")
+ headers = _get_auth_headers(auth_type, auth_data)
+ headers["Content-Type"] = "application/json"
+
+ payload: dict[str, Any] = {}
+
+ filter_obj: dict[str, Any] = {}
+ if from_date_time is not None:
+ filter_obj["fromDateTime"] = from_date_time
+ if to_date_time is not None:
+ filter_obj["toDateTime"] = to_date_time
+ if workspace_id is not None:
+ filter_obj["workspaceId"] = workspace_id
+ if call_ids is not None:
+ filter_obj["callIds"] = call_ids
+ if primary_user_ids is not None:
+ filter_obj["primaryUserIds"] = primary_user_ids
+ if filter_obj:
+ payload["filter"] = filter_obj
+
+ content_selector: dict[str, Any] = {}
+ if context != "None":
+ content_selector["context"] = context
+ if context_timing is not None:
+ content_selector["contextTiming"] = context_timing
+ if include_parties:
+ content_selector["includeParties"] = True
+ if exposed_fields_content is not None:
+ content_selector["exposedFields"] = content_selector.get("exposedFields", {})
+ content_selector["exposedFields"]["content"] = exposed_fields_content
+ if exposed_fields_interaction is not None:
+ content_selector["exposedFields"] = content_selector.get("exposedFields", {})
+ content_selector["exposedFields"]["interaction"] = exposed_fields_interaction
+ if include_public_comments:
+ content_selector["includePublicComments"] = True
+ if include_media:
+ content_selector["includeMedia"] = True
+ if content_selector:
+ payload["contentSelector"] = content_selector
+
+ all_calls: list[dict[str, Any]] = []
+ cursor: str | None = None
+ collected = 0
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ while collected < max_results:
+ request_payload = dict(payload)
+ if cursor is not None:
+ request_payload["cursor"] = cursor
+
+ response = await client.post(
+ f"{_BASE_URL}/calls/extensive",
+ headers=headers,
+ json=request_payload,
+ )
+ if response.status_code != 200:
+ return GetExtensiveDataOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ calls = data.get("calls", [])
+ all_calls.extend(calls)
+ collected += len(calls)
+
+ records = data.get("records", {})
+ cursor = records.get("cursor")
+ if not cursor or not calls:
+ break
+ except httpx.TimeoutException:
+ return GetExtensiveDataOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return GetExtensiveDataOutput(success=False, error=f"Call failed: {exc}")
+
+ return GetExtensiveDataOutput(success=True, calls=all_calls[:max_results])
+
+
+@tool(args_schema=ListCallsInput)
+@serialize_pydantic_return
+async def list_calls(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ from_date_time: str | None = None,
+ to_date_time: str | None = None,
+ cursor: str | None = None,
+) -> ListCallsOutput:
+ """List calls with optional date range filtering."""
+ if not auth_data.get("access_token"):
+ return ListCallsOutput(success=False, error="Missing or empty access_token in auth_data.")
+ headers = _get_auth_headers(auth_type, auth_data)
+
+ params: dict[str, str] = {}
+ if from_date_time is not None:
+ params["fromDateTime"] = from_date_time
+ if to_date_time is not None:
+ params["toDateTime"] = to_date_time
+ if cursor is not None:
+ params["cursor"] = cursor
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{_BASE_URL}/calls",
+ headers=headers,
+ params=params,
+ )
+ if response.status_code != 200:
+ return ListCallsOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return ListCallsOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return ListCallsOutput(success=False, error=f"Call failed: {exc}")
+
+ return ListCallsOutput(
+ success=True,
+ request_id=data.get("requestId"),
+ cursor=(data.get("records") or {}).get("cursor"),
+ calls=data.get("calls", []),
+ )
+
+
+@tool(args_schema=ListWorkspaceIdOptionsInput)
+@serialize_pydantic_return
+async def list_workspace_id_options(
+ auth_type: str,
+ auth_data: dict[str, Any],
+) -> ListWorkspaceIdOptionsOutput:
+ """Retrieve available workspace IDs and names."""
+ if not auth_data.get("access_token"):
+ return ListWorkspaceIdOptionsOutput(success=False, error="Missing or empty access_token in auth_data.")
+ headers = _get_auth_headers(auth_type, auth_data)
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{_BASE_URL}/workspaces",
+ headers=headers,
+ )
+ if response.status_code != 200:
+ return ListWorkspaceIdOptionsOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return ListWorkspaceIdOptionsOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return ListWorkspaceIdOptionsOutput(success=False, error=f"Call failed: {exc}")
+
+ raw_workspaces = data.get("workspaces", [])
+ workspaces = [
+ Workspace(id=w.get("id"), name=w.get("name"))
+ for w in raw_workspaces
+ ]
+ return ListWorkspaceIdOptionsOutput(success=True, workspaces=workspaces)
+
+
+@tool(args_schema=RetrieveTranscriptsOfCallsInput)
+@serialize_pydantic_return
+async def retrieve_transcripts_of_calls(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ from_date_time: str | None = None,
+ to_date_time: str | None = None,
+ workspace_id: str | None = None,
+ call_ids: list[str] | None = None,
+) -> RetrieveTranscriptsOfCallsOutput:
+ """Retrieve transcripts of calls with optional date range and call ID filtering."""
+ if not auth_data.get("access_token"):
+ return RetrieveTranscriptsOfCallsOutput(success=False, error="Missing or empty access_token in auth_data.")
+ headers = _get_auth_headers(auth_type, auth_data)
+ headers["Content-Type"] = "application/json"
+
+ filter_obj: dict[str, Any] = {}
+ if from_date_time is not None:
+ filter_obj["fromDateTime"] = from_date_time
+ if to_date_time is not None:
+ filter_obj["toDateTime"] = to_date_time
+ if workspace_id is not None:
+ filter_obj["workspaceId"] = workspace_id
+ if call_ids is not None:
+ filter_obj["callIds"] = call_ids
+
+ payload: dict[str, Any] = {}
+ if filter_obj:
+ payload["filter"] = filter_obj
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.post(
+ f"{_BASE_URL}/calls/transcript",
+ headers=headers,
+ json=payload,
+ )
+ if response.status_code != 200:
+ return RetrieveTranscriptsOfCallsOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return RetrieveTranscriptsOfCallsOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return RetrieveTranscriptsOfCallsOutput(success=False, error=f"Call failed: {exc}")
+
+ return RetrieveTranscriptsOfCallsOutput(
+ success=True,
+ call_transcripts=data.get("callTranscripts", []),
+ )
diff --git a/src/modulex_integrations/tools/heygen/README.md b/src/modulex_integrations/tools/heygen/README.md
new file mode 100644
index 0000000..a2ddb82
--- /dev/null
+++ b/src/modulex_integrations/tools/heygen/README.md
@@ -0,0 +1,33 @@
+# HeyGen
+
+AI video generation platform for creating talking avatar videos via the HeyGen REST API (`api.heygen.com`).
+
+## Authentication
+
+### API Key Authentication
+
+- Sign in at , go to **Settings > API**, and copy your API key.
+- Required env var: `HEYGEN_API_KEY` (format: `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`).
+
+## Tools
+
+| name | description | required params |
+| --- | --- | --- |
+| `create_talking_photo` | Creates a talking photo video from a provided image, text, and voice | `talking_photo_id`, `text`, `voice_id` |
+| `create_video_from_template` | Generates a video from a selected template with optional variable overrides | `template_id` |
+| `list_custom_events_options` | Retrieves available options for webhook custom events | |
+| `list_voice_id_options` | Retrieves available voice options for video generation | |
+| `retrieve_video_link` | Fetches the status and download link for a specific HeyGen video | `video_id` |
+
+Every tool takes an additional `api_key` parameter that the runtime fills in from the resolved credential.
+
+## Limits & Quotas
+
+- Rate limits depend on your HeyGen plan tier (Starter, Business, Enterprise).
+- Video generation consumes credits; use `test=true` to avoid credit charges during development.
+- No documented per-minute rate limit; excessive requests may trigger throttling.
+- Error model: non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising.
+
+## Maintainer
+
+ModuleX core team.
diff --git a/src/modulex_integrations/tools/heygen/__init__.py b/src/modulex_integrations/tools/heygen/__init__.py
new file mode 100644
index 0000000..a2c2f4c
--- /dev/null
+++ b/src/modulex_integrations/tools/heygen/__init__.py
@@ -0,0 +1,27 @@
+"""HeyGen integration — discovered via the ``modulex.tools`` entry point."""
+from modulex_integrations.tools.heygen.manifest import manifest
+from modulex_integrations.tools.heygen.tools import (
+ create_talking_photo,
+ create_video_from_template,
+ list_custom_events_options,
+ list_voice_id_options,
+ retrieve_video_link,
+)
+
+TOOLS = (
+ create_talking_photo,
+ create_video_from_template,
+ list_custom_events_options,
+ list_voice_id_options,
+ retrieve_video_link,
+)
+
+__all__ = [
+ "TOOLS",
+ "create_talking_photo",
+ "create_video_from_template",
+ "list_custom_events_options",
+ "list_voice_id_options",
+ "manifest",
+ "retrieve_video_link",
+]
diff --git a/src/modulex_integrations/tools/heygen/dependencies.toml b/src/modulex_integrations/tools/heygen/dependencies.toml
new file mode 100644
index 0000000..4e7b90b
--- /dev/null
+++ b/src/modulex_integrations/tools/heygen/dependencies.toml
@@ -0,0 +1,3 @@
+# Runtime dependencies for the heygen integration.
+# CI assembles this into the root pyproject's [project.optional-dependencies].
+dependencies = []
diff --git a/src/modulex_integrations/tools/heygen/manifest.py b/src/modulex_integrations/tools/heygen/manifest.py
new file mode 100644
index 0000000..c8e7a76
--- /dev/null
+++ b/src/modulex_integrations/tools/heygen/manifest.py
@@ -0,0 +1,167 @@
+"""HeyGen integration manifest."""
+from __future__ import annotations
+
+from modulex_integrations.schema import (
+ ActionDefinition,
+ ApiKeyAuthSchema,
+ EnvVar,
+ IntegrationManifest,
+ ParameterDef,
+ SuccessIndicators,
+ TestEndpoint,
+)
+
+__all__ = ["manifest"]
+
+
+manifest = IntegrationManifest(
+ name="heygen",
+ display_name="HeyGen",
+ description="AI video generation platform for creating talking avatar videos",
+ version="1.0.0",
+ author="ModuleX",
+ logo="modulex:heygen",
+ app_url="https://www.heygen.com",
+ categories=["AI", "Video", "Content Creation"],
+ actions=[
+ ActionDefinition(
+ name="create_talking_photo",
+ description="Creates a talking photo video from a provided image, text, and voice",
+ parameters={
+ "talking_photo_id": ParameterDef(
+ type="string",
+ description="Identifier of the talking photo to use",
+ required=True,
+ ),
+ "text": ParameterDef(
+ type="string",
+ description="The text that the character will speak",
+ required=True,
+ ),
+ "voice_id": ParameterDef(
+ type="string",
+ description="Identifier of the voice to use",
+ required=True,
+ ),
+ "title": ParameterDef(
+ type="string",
+ description="Title of the video",
+ ),
+ "test": ParameterDef(
+ type="boolean",
+ description="Set to true to use test mode (no credits charged, watermark added)",
+ ),
+ "caption": ParameterDef(
+ type="boolean",
+ description="Set to true to create video with captions",
+ ),
+ "scale": ParameterDef(
+ type="string",
+ description="Talking photo scale, value between 0 and 2.0 (default 1.0)",
+ ),
+ "talking_photo_style": ParameterDef(
+ type="string",
+ description="Talking photo crop style: square, circle",
+ ),
+ "talking_style": ParameterDef(
+ type="string",
+ description="Talking photo talking style: stable, expressive",
+ ),
+ "expression": ParameterDef(
+ type="string",
+ description="Talking photo expression style: default, happy",
+ ),
+ "super_resolution": ParameterDef(
+ type="boolean",
+ description="Whether to enhance the photo image",
+ ),
+ "matting": ParameterDef(
+ type="boolean",
+ description="Whether to apply matting to the photo",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="create_video_from_template",
+ description="Generates a video from a selected template with optional variable overrides",
+ parameters={
+ "template_id": ParameterDef(
+ type="string",
+ description="Identifier of the template to use",
+ required=True,
+ ),
+ "title": ParameterDef(
+ type="string",
+ description="Title of the video",
+ ),
+ "test": ParameterDef(
+ type="boolean",
+ description="Set to true to use test mode (no credits charged, watermark added)",
+ ),
+ "caption": ParameterDef(
+ type="boolean",
+ description="Set to true to create video with captions",
+ ),
+ "variables": ParameterDef(
+ type="object",
+ description="Template variable overrides as a JSON object where keys are variable names and values are objects with variable properties",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="list_custom_events_options",
+ description="Retrieves available options for webhook custom events",
+ parameters={},
+ ),
+ ActionDefinition(
+ name="list_voice_id_options",
+ description="Retrieves available voice options for video generation",
+ parameters={},
+ ),
+ ActionDefinition(
+ name="retrieve_video_link",
+ description="Fetches the status and download link for a specific HeyGen video",
+ parameters={
+ "video_id": ParameterDef(
+ type="string",
+ description="Identifier of the HeyGen video to retrieve",
+ required=True,
+ ),
+ },
+ ),
+ ],
+ auth_schemas=[
+ ApiKeyAuthSchema(
+ display_name="API Key Authentication",
+ description="Authenticate using your HeyGen API key",
+ setup_instructions=[
+ "Go to https://app.heygen.com and sign in",
+ "Navigate to Settings > API",
+ "Copy your API key",
+ "Paste the API key below",
+ ],
+ setup_environment_variables=[
+ EnvVar(
+ name="HEYGEN_API_KEY",
+ display_name="HeyGen API Key",
+ description="Your HeyGen API key from app.heygen.com/settings",
+ required=True,
+ sensitive=True,
+ sample_format="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ about_url="https://app.heygen.com/settings",
+ ),
+ ],
+ test_endpoint=TestEndpoint(
+ url="https://api.heygen.com/v2/voices",
+ method="GET",
+ headers={"X-Api-Key": "{api_key}"},
+ success_indicators=SuccessIndicators(
+ status_codes=[200],
+ response_fields=["data"],
+ ),
+ cost_level="free",
+ description="Validates the API key by listing available voices",
+ ),
+ ),
+ ],
+)
diff --git a/src/modulex_integrations/tools/heygen/outputs.py b/src/modulex_integrations/tools/heygen/outputs.py
new file mode 100644
index 0000000..8f9e184
--- /dev/null
+++ b/src/modulex_integrations/tools/heygen/outputs.py
@@ -0,0 +1,69 @@
+"""Pydantic response models for the heygen integration's @tool functions."""
+from __future__ import annotations
+
+from pydantic import BaseModel, ConfigDict, Field
+
+__all__ = [
+ "CreateTalkingPhotoOutput",
+ "CreateVideoFromTemplateOutput",
+ "ListCustomEventsOptionsOutput",
+ "ListVoiceIdOptionsOutput",
+ "RetrieveVideoLinkOutput",
+ "VoiceInfo",
+]
+
+
+class _Base(BaseModel):
+ """Shared config for every output model in this integration."""
+
+ model_config = ConfigDict(extra="forbid")
+
+
+# --- Nested resource models -----------------------------------------------
+
+
+class VoiceInfo(_Base):
+ """A voice entry returned by the list voices endpoint."""
+
+ voice_id: str | None = None
+ name: str | None = None
+
+
+# --- Per-action output models ---------------------------------------------
+
+
+class CreateTalkingPhotoOutput(_Base):
+ success: bool
+ error: str | None = None
+ video_id: str | None = None
+ status: str | None = None
+
+
+class CreateVideoFromTemplateOutput(_Base):
+ success: bool
+ error: str | None = None
+ video_id: str | None = None
+ status: str | None = None
+
+
+class ListCustomEventsOptionsOutput(_Base):
+ success: bool
+ error: str | None = None
+ events: list[str] = Field(default_factory=list)
+
+
+class ListVoiceIdOptionsOutput(_Base):
+ success: bool
+ error: str | None = None
+ voices: list[VoiceInfo] = Field(default_factory=list)
+
+
+class RetrieveVideoLinkOutput(_Base):
+ success: bool
+ error: str | None = None
+ video_id: str | None = None
+ status: str | None = None
+ video_url: str | None = None
+ thumbnail_url: str | None = None
+ duration: float | None = None
+ caption_url: str | None = None
diff --git a/src/modulex_integrations/tools/heygen/tests/__init__.py b/src/modulex_integrations/tools/heygen/tests/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/modulex_integrations/tools/heygen/tests/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/modulex_integrations/tools/heygen/tests/test_heygen.py b/src/modulex_integrations/tools/heygen/tests/test_heygen.py
new file mode 100644
index 0000000..d9bb3cb
--- /dev/null
+++ b/src/modulex_integrations/tools/heygen/tests/test_heygen.py
@@ -0,0 +1,183 @@
+"""Happy-path tests for every heygen @tool, plus a manifest sanity check."""
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from modulex_integrations.tools.heygen import (
+ TOOLS,
+ create_talking_photo,
+ create_video_from_template,
+ list_custom_events_options,
+ list_voice_id_options,
+ manifest,
+ retrieve_video_link,
+)
+from modulex_integrations.tools.heygen.outputs import (
+ CreateTalkingPhotoOutput,
+ CreateVideoFromTemplateOutput,
+ ListCustomEventsOptionsOutput,
+ ListVoiceIdOptionsOutput,
+ RetrieveVideoLinkOutput,
+)
+
+API = "https://api.heygen.com"
+
+_API_KEY = "fake-api-key"
+
+
+def _args(**extra: Any) -> dict[str, Any]:
+ return dict(api_key=_API_KEY, **extra)
+
+
+# --- Manifest sanity --------------------------------------------------------
+
+
+class TestManifest:
+ def test_manifest_exposes_5_actions(self) -> None:
+ assert len(manifest.actions) == 5
+
+ def test_manifest_actions_match_tools_tuple(self) -> None:
+ assert {a.name for a in manifest.actions} == {t.name for t in TOOLS}
+
+ def test_manifest_has_api_key_auth(self) -> None:
+ assert {a.auth_type for a in manifest.auth_schemas} == {"api_key"}
+
+
+# --- Per-action happy-path tests -------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_create_talking_photo(httpx_mock) -> None: # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/v2/video/generate",
+ json={
+ "data": {
+ "video_id": "vid_123",
+ "status": "processing",
+ },
+ },
+ )
+
+ result_dict = await create_talking_photo.ainvoke(
+ _args(
+ talking_photo_id="tp_abc",
+ text="Hello world",
+ voice_id="voice_xyz",
+ )
+ )
+
+ assert isinstance(result_dict, dict)
+ result = CreateTalkingPhotoOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.video_id == "vid_123"
+ assert result.status == "processing"
+
+
+@pytest.mark.asyncio
+async def test_create_video_from_template(httpx_mock) -> None: # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/v2/template/tmpl_001/generate",
+ json={
+ "data": {
+ "video_id": "vid_456",
+ "status": "processing",
+ },
+ },
+ )
+
+ result_dict = await create_video_from_template.ainvoke(
+ _args(template_id="tmpl_001")
+ )
+
+ assert isinstance(result_dict, dict)
+ result = CreateVideoFromTemplateOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.video_id == "vid_456"
+ assert result.status == "processing"
+
+
+@pytest.mark.asyncio
+async def test_list_custom_events_options(httpx_mock) -> None: # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/v1/webhook/webhook.list",
+ json={
+ "data": [
+ "avatar_video.success",
+ "avatar_video.fail",
+ ],
+ },
+ )
+
+ result_dict = await list_custom_events_options.ainvoke(_args())
+
+ assert isinstance(result_dict, dict)
+ result = ListCustomEventsOptionsOutput.model_validate(result_dict)
+ assert result.success is True
+ assert "avatar_video.success" in result.events
+
+
+@pytest.mark.asyncio
+async def test_list_voice_id_options(httpx_mock) -> None: # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/v2/voices",
+ json={
+ "data": {
+ "voices": [
+ {"voice_id": "v1", "name": "Sara"},
+ {"voice_id": "v2", "name": "Mark"},
+ ],
+ },
+ },
+ )
+
+ result_dict = await list_voice_id_options.ainvoke(_args())
+
+ assert isinstance(result_dict, dict)
+ result = ListVoiceIdOptionsOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.voices) == 2
+ assert result.voices[0].voice_id == "v1"
+
+
+@pytest.mark.asyncio
+async def test_retrieve_video_link(httpx_mock) -> None: # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/v1/video_status.get?video_id=vid_123",
+ json={
+ "data": {
+ "video_id": "vid_123",
+ "status": "completed",
+ "video_url": "https://files.heygen.ai/video.mp4",
+ "thumbnail_url": "https://files.heygen.ai/thumb.jpg",
+ "duration": 12.5,
+ "caption_url": "https://files.heygen.ai/caption.srt",
+ },
+ },
+ )
+
+ result_dict = await retrieve_video_link.ainvoke(_args(video_id="vid_123"))
+
+ assert isinstance(result_dict, dict)
+ result = RetrieveVideoLinkOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.video_id == "vid_123"
+ assert result.status == "completed"
+ assert result.video_url == "https://files.heygen.ai/video.mp4"
+ assert result.duration == 12.5
+
+
+@pytest.mark.asyncio
+async def test_create_talking_photo_validates_empty_api_key() -> None:
+ result_dict = await create_talking_photo.ainvoke(
+ {"talking_photo_id": "tp", "text": "hi", "voice_id": "v", "api_key": ""}
+ )
+ result = CreateTalkingPhotoOutput.model_validate(result_dict)
+ assert result.success is False
+ assert "API key" in (result.error or "")
diff --git a/src/modulex_integrations/tools/heygen/tools.py b/src/modulex_integrations/tools/heygen/tools.py
new file mode 100644
index 0000000..ad85aba
--- /dev/null
+++ b/src/modulex_integrations/tools/heygen/tools.py
@@ -0,0 +1,338 @@
+"""HeyGen LangChain @tool functions."""
+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.heygen.outputs import (
+ CreateTalkingPhotoOutput,
+ CreateVideoFromTemplateOutput,
+ ListCustomEventsOptionsOutput,
+ ListVoiceIdOptionsOutput,
+ RetrieveVideoLinkOutput,
+ VoiceInfo,
+)
+
+__all__ = [
+ "create_talking_photo",
+ "create_video_from_template",
+ "list_custom_events_options",
+ "list_voice_id_options",
+ "retrieve_video_link",
+]
+
+_BASE_URL = "https://api.heygen.com"
+_TIMEOUT = 30.0
+
+
+def _headers(api_key: str) -> dict[str, str]:
+ return {
+ "X-Api-Key": api_key,
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ }
+
+
+# --- Input schemas --------------------------------------------------------
+
+
+class CreateTalkingPhotoInput(BaseModel):
+ talking_photo_id: str = Field(description="Identifier of the talking photo to use")
+ text: str = Field(description="The text that the character will speak")
+ voice_id: str = Field(description="Identifier of the voice to use")
+ api_key: str = Field(description="HeyGen API key")
+ title: str | None = Field(default=None, description="Title of the video")
+ test: bool | None = Field(default=None, description="Set to true to use test mode (no credits charged, watermark added)")
+ caption: bool | None = Field(default=None, description="Set to true to create video with captions")
+ scale: str | None = Field(default=None, description="Talking photo scale, value between 0 and 2.0 (default 1.0)")
+ talking_photo_style: str | None = Field(default=None, description="Talking photo crop style: square, circle")
+ talking_style: str | None = Field(default=None, description="Talking photo talking style: stable, expressive")
+ expression: str | None = Field(default=None, description="Talking photo expression style: default, happy")
+ super_resolution: bool | None = Field(default=None, description="Whether to enhance the photo image")
+ matting: bool | None = Field(default=None, description="Whether to apply matting to the photo")
+
+
+class CreateVideoFromTemplateInput(BaseModel):
+ template_id: str = Field(description="Identifier of the template to use")
+ api_key: str = Field(description="HeyGen API key")
+ title: str | None = Field(default=None, description="Title of the video")
+ test: bool | None = Field(default=None, description="Set to true to use test mode (no credits charged, watermark added)")
+ caption: bool | None = Field(default=None, description="Set to true to create video with captions")
+ variables: dict[str, Any] | None = Field(default=None, description="Template variable overrides as a JSON object where keys are variable names and values are objects with variable properties")
+
+
+class ListCustomEventsOptionsInput(BaseModel):
+ api_key: str = Field(description="HeyGen API key")
+
+
+class ListVoiceIdOptionsInput(BaseModel):
+ api_key: str = Field(description="HeyGen API key")
+
+
+class RetrieveVideoLinkInput(BaseModel):
+ video_id: str = Field(description="Identifier of the HeyGen video to retrieve")
+ api_key: str = Field(description="HeyGen API key")
+
+
+# --- @tool functions ------------------------------------------------------
+
+
+@tool(args_schema=CreateTalkingPhotoInput)
+@serialize_pydantic_return
+async def create_talking_photo(
+ talking_photo_id: str,
+ text: str,
+ voice_id: str,
+ api_key: str,
+ title: str | None = None,
+ test: bool | None = None,
+ caption: bool | None = None,
+ scale: str | None = None,
+ talking_photo_style: str | None = None,
+ talking_style: str | None = None,
+ expression: str | None = None,
+ super_resolution: bool | None = None,
+ matting: bool | None = None,
+) -> CreateTalkingPhotoOutput:
+ """Creates a talking photo video from a provided image, text, and voice"""
+ if not api_key or not api_key.strip():
+ return CreateTalkingPhotoOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+
+ talking_photo_config: dict[str, Any] = {
+ "type": "talking_photo",
+ "talking_photo_id": talking_photo_id,
+ }
+ if talking_photo_style is not None:
+ talking_photo_config["talking_photo_style"] = talking_photo_style
+ if talking_style is not None:
+ talking_photo_config["talking_style"] = talking_style
+ if expression is not None:
+ talking_photo_config["expression"] = expression
+ if super_resolution is not None:
+ talking_photo_config["super_resolution"] = super_resolution
+ if matting is not None:
+ talking_photo_config["matting"] = matting
+ if scale is not None:
+ talking_photo_config["scale"] = float(scale)
+
+ voice_config: dict[str, Any] = {
+ "type": "text",
+ "voice_id": voice_id,
+ "input_text": text,
+ }
+
+ video_input: dict[str, Any] = {
+ "character": talking_photo_config,
+ "voice": voice_config,
+ }
+
+ payload: dict[str, Any] = {
+ "video_inputs": [video_input],
+ }
+ if title is not None:
+ payload["title"] = title
+ if test is not None:
+ payload["test"] = test
+ if caption is not None:
+ payload["caption"] = caption
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.post(
+ f"{_BASE_URL}/v2/video/generate",
+ headers=_headers(api_key),
+ json=payload,
+ )
+ if response.status_code != 200:
+ return CreateTalkingPhotoOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return CreateTalkingPhotoOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return CreateTalkingPhotoOutput(success=False, error=f"Call failed: {exc}")
+
+ video_data = data.get("data", {})
+ return CreateTalkingPhotoOutput(
+ success=True,
+ video_id=video_data.get("video_id"),
+ status=video_data.get("status"),
+ )
+
+
+@tool(args_schema=CreateVideoFromTemplateInput)
+@serialize_pydantic_return
+async def create_video_from_template(
+ template_id: str,
+ api_key: str,
+ title: str | None = None,
+ test: bool | None = None,
+ caption: bool | None = None,
+ variables: dict[str, Any] | None = None,
+) -> CreateVideoFromTemplateOutput:
+ """Generates a video from a selected template with optional variable overrides"""
+ if not api_key or not api_key.strip():
+ return CreateVideoFromTemplateOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+
+ payload: dict[str, Any] = {}
+ if title is not None:
+ payload["title"] = title
+ if test is not None:
+ payload["test"] = test
+ if caption is not None:
+ payload["caption"] = caption
+ if variables is not None:
+ payload["variables"] = variables
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.post(
+ f"{_BASE_URL}/v2/template/{template_id}/generate",
+ headers=_headers(api_key),
+ json=payload,
+ )
+ if response.status_code != 200:
+ return CreateVideoFromTemplateOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return CreateVideoFromTemplateOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return CreateVideoFromTemplateOutput(success=False, error=f"Call failed: {exc}")
+
+ video_data = data.get("data", {})
+ return CreateVideoFromTemplateOutput(
+ success=True,
+ video_id=video_data.get("video_id"),
+ status=video_data.get("status"),
+ )
+
+
+@tool(args_schema=ListCustomEventsOptionsInput)
+@serialize_pydantic_return
+async def list_custom_events_options(
+ api_key: str,
+) -> ListCustomEventsOptionsOutput:
+ """Retrieves available options for webhook custom events"""
+ if not api_key or not api_key.strip():
+ return ListCustomEventsOptionsOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{_BASE_URL}/v1/webhook/webhook.list",
+ headers=_headers(api_key),
+ )
+ if response.status_code != 200:
+ return ListCustomEventsOptionsOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return ListCustomEventsOptionsOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return ListCustomEventsOptionsOutput(success=False, error=f"Call failed: {exc}")
+
+ events = data.get("data", [])
+ if isinstance(events, list):
+ return ListCustomEventsOptionsOutput(success=True, events=events)
+ return ListCustomEventsOptionsOutput(success=True, events=[])
+
+
+@tool(args_schema=ListVoiceIdOptionsInput)
+@serialize_pydantic_return
+async def list_voice_id_options(
+ api_key: str,
+) -> ListVoiceIdOptionsOutput:
+ """Retrieves available voice options for video generation"""
+ if not api_key or not api_key.strip():
+ return ListVoiceIdOptionsOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{_BASE_URL}/v2/voices",
+ headers=_headers(api_key),
+ )
+ if response.status_code != 200:
+ return ListVoiceIdOptionsOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return ListVoiceIdOptionsOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return ListVoiceIdOptionsOutput(success=False, error=f"Call failed: {exc}")
+
+ voices_data = data.get("data", {}).get("voices", [])
+ voices = [
+ VoiceInfo(voice_id=v.get("voice_id"), name=v.get("name"))
+ for v in voices_data
+ if isinstance(v, dict)
+ ]
+ return ListVoiceIdOptionsOutput(success=True, voices=voices)
+
+
+@tool(args_schema=RetrieveVideoLinkInput)
+@serialize_pydantic_return
+async def retrieve_video_link(
+ video_id: str,
+ api_key: str,
+) -> RetrieveVideoLinkOutput:
+ """Fetches the status and download link for a specific HeyGen video"""
+ if not api_key or not api_key.strip():
+ return RetrieveVideoLinkOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{_BASE_URL}/v1/video_status.get",
+ headers=_headers(api_key),
+ params={"video_id": video_id},
+ )
+ if response.status_code != 200:
+ return RetrieveVideoLinkOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return RetrieveVideoLinkOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return RetrieveVideoLinkOutput(success=False, error=f"Call failed: {exc}")
+
+ video_data = data.get("data", {})
+ return RetrieveVideoLinkOutput(
+ success=True,
+ video_id=video_data.get("video_id"),
+ status=video_data.get("status"),
+ video_url=video_data.get("video_url"),
+ thumbnail_url=video_data.get("thumbnail_url"),
+ duration=video_data.get("duration"),
+ caption_url=video_data.get("caption_url"),
+ )
diff --git a/src/modulex_integrations/tools/hunter/README.md b/src/modulex_integrations/tools/hunter/README.md
new file mode 100644
index 0000000..37cabdb
--- /dev/null
+++ b/src/modulex_integrations/tools/hunter/README.md
@@ -0,0 +1,42 @@
+# Hunter
+
+Find and verify professional email addresses, search domains for contacts, and manage leads using the Hunter.io API (`api.hunter.io/v2`).
+
+## Authentication
+
+### API Key Authentication
+
+- Sign in at and navigate to [API Keys](https://hunter.io/api-keys).
+- Copy your API key.
+- Required env var: `HUNTER_API_KEY` (format: 40-character hex string).
+
+## Tools
+
+| name | description | required params |
+| --- | --- | --- |
+| `account_information` | Get information about your Hunter account | _(none)_ |
+| `combined_enrichment` | Returns all the information associated with an email address and its domain name | `email` |
+| `create_lead` | Create a new lead in your Hunter account | `email` |
+| `delete_lead` | Delete an existing lead from your Hunter account | `lead_id` |
+| `domain_search` | Search all the email addresses corresponding to one website or company | `limit` |
+| `email_count` | Get the number of email addresses Hunter has for one domain or company | _(none)_ |
+| `email_finder` | Find the most likely email address from a domain name, a first name and a last name | `first_name`, `last_name` |
+| `email_verifier` | Check the deliverability of a given email address | `email` |
+| `get_lead` | Retrieve one of your leads by ID | `lead_id` |
+| `get_leads_list` | Retrieves all the fields of a leads list, including its leads | `leads_list_id`, `limit` |
+| `list_leads` | List all your leads with comprehensive filtering options | `limit` |
+| `list_leads_lists` | List all your leads lists, sorted with the most recent first | `limit` |
+| `update_lead` | Update an existing lead in your Hunter account | `lead_id` |
+
+Every tool takes an additional `api_key` parameter that the runtime fills in from the resolved credential.
+
+## Limits & Quotas
+
+- **Free plan**: 25 searches and 50 verifications per month.
+- **Paid plans**: Limits scale with plan tier (up to 30,000+ requests/month on Enterprise).
+- **Rate limit**: 10 requests/second across all endpoints.
+- **Error model**: Non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising.
+
+## Maintainer
+
+ModuleX core team.
diff --git a/src/modulex_integrations/tools/hunter/__init__.py b/src/modulex_integrations/tools/hunter/__init__.py
new file mode 100644
index 0000000..43ab214
--- /dev/null
+++ b/src/modulex_integrations/tools/hunter/__init__.py
@@ -0,0 +1,51 @@
+"""Hunter integration — discovered via the ``modulex.tools`` entry point."""
+from modulex_integrations.tools.hunter.manifest import manifest
+from modulex_integrations.tools.hunter.tools import (
+ account_information,
+ combined_enrichment,
+ create_lead,
+ delete_lead,
+ domain_search,
+ email_count,
+ email_finder,
+ email_verifier,
+ get_lead,
+ get_leads_list,
+ list_leads,
+ list_leads_lists,
+ update_lead,
+)
+
+TOOLS = (
+ account_information,
+ combined_enrichment,
+ create_lead,
+ delete_lead,
+ domain_search,
+ email_count,
+ email_finder,
+ email_verifier,
+ get_lead,
+ get_leads_list,
+ list_leads,
+ list_leads_lists,
+ update_lead,
+)
+
+__all__ = [
+ "TOOLS",
+ "account_information",
+ "combined_enrichment",
+ "create_lead",
+ "delete_lead",
+ "domain_search",
+ "email_count",
+ "email_finder",
+ "email_verifier",
+ "get_lead",
+ "get_leads_list",
+ "list_leads",
+ "list_leads_lists",
+ "manifest",
+ "update_lead",
+]
diff --git a/src/modulex_integrations/tools/hunter/dependencies.toml b/src/modulex_integrations/tools/hunter/dependencies.toml
new file mode 100644
index 0000000..ad88993
--- /dev/null
+++ b/src/modulex_integrations/tools/hunter/dependencies.toml
@@ -0,0 +1,3 @@
+# Runtime dependencies for the hunter integration.
+# CI assembles this into the root pyproject's [project.optional-dependencies].
+dependencies = []
diff --git a/src/modulex_integrations/tools/hunter/manifest.py b/src/modulex_integrations/tools/hunter/manifest.py
new file mode 100644
index 0000000..6b5653f
--- /dev/null
+++ b/src/modulex_integrations/tools/hunter/manifest.py
@@ -0,0 +1,417 @@
+"""Hunter integration manifest."""
+from __future__ import annotations
+
+from modulex_integrations.schema import (
+ ActionDefinition,
+ ApiKeyAuthSchema,
+ EnvVar,
+ IntegrationManifest,
+ ParameterDef,
+ SuccessIndicators,
+ TestEndpoint,
+)
+
+__all__ = ["manifest"]
+
+
+manifest = IntegrationManifest(
+ name="hunter",
+ display_name="Hunter",
+ description="Find and verify professional email addresses using the Hunter.io API",
+ version="1.0.0",
+ author="ModuleX",
+ logo="modulex:hunter",
+ app_url="https://hunter.io",
+ categories=["Marketing & Sales", "Lead Generation", "Email"],
+ actions=[
+ ActionDefinition(
+ name="account_information",
+ description="Get information about your Hunter account",
+ parameters={},
+ ),
+ ActionDefinition(
+ name="combined_enrichment",
+ description="Returns all the information associated with an email address and its domain name",
+ parameters={
+ "email": ParameterDef(
+ type="string",
+ description="The email address you want to find information about",
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="create_lead",
+ description="Create a new lead in your Hunter account",
+ parameters={
+ "email": ParameterDef(
+ type="string",
+ description="The email address of the lead",
+ required=True,
+ ),
+ "first_name": ParameterDef(
+ type="string",
+ description="The first name of the lead",
+ ),
+ "last_name": ParameterDef(
+ type="string",
+ description="The last name of the lead",
+ ),
+ "position": ParameterDef(
+ type="string",
+ description="The job title of the lead",
+ ),
+ "company": ParameterDef(
+ type="string",
+ description="The name of the company the lead is working in",
+ ),
+ "company_industry": ParameterDef(
+ type="string",
+ description="The sector of the company. Allowed values: Animal, Art & Entertainment, Automotive, Beauty & Fitness, Books & Literature, Education & Career, Finance, Food & Drink, Game, Health, Hobby & Leisure, Home & Garden, Industry, Internet & Telecom, Law & Government, Manufacturing, News, Real Estate, Science, Retail, Sport, Technology, Travel",
+ ),
+ "company_size": ParameterDef(
+ type="string",
+ description="The size of the company the lead is working in",
+ ),
+ "confidence_score": ParameterDef(
+ type="integer",
+ description="Estimation of the probability the email address returned is correct, between 0 and 100",
+ ),
+ "website": ParameterDef(
+ type="string",
+ description="The domain name of the company",
+ ),
+ "country_code": ParameterDef(
+ type="string",
+ description="The country of the lead (ISO 3166-1 alpha-2 standard)",
+ ),
+ "linkedin_url": ParameterDef(
+ type="string",
+ description="The address of the public profile on LinkedIn",
+ ),
+ "phone_number": ParameterDef(
+ type="string",
+ description="The phone number of the lead",
+ ),
+ "twitter": ParameterDef(
+ type="string",
+ description="The Twitter handle of the lead",
+ ),
+ "notes": ParameterDef(
+ type="string",
+ description="Some personal notes about the lead",
+ ),
+ "source": ParameterDef(
+ type="string",
+ description="The source where the lead has been found",
+ ),
+ "leads_list_id": ParameterDef(
+ type="string",
+ description="The identifier of the list the lead belongs to. If not specified, the lead is saved in the last list created",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="delete_lead",
+ description="Delete an existing lead from your Hunter account",
+ parameters={
+ "lead_id": ParameterDef(
+ type="string",
+ description="The unique identifier of the lead",
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="domain_search",
+ description="Search all the email addresses corresponding to one website or company",
+ parameters={
+ "domain": ParameterDef(
+ type="string",
+ description="Domain name from which you want to find the email addresses. For example, 'stripe.com'. Either domain or company must be provided",
+ ),
+ "company": ParameterDef(
+ type="string",
+ description="The company name from which you want to find the email addresses. For example, 'stripe'. Either domain or company must be provided",
+ ),
+ "limit": ParameterDef(
+ type="integer",
+ description="Specifies the max number of email addresses to return",
+ default=100,
+ required=True,
+ ),
+ "type": ParameterDef(
+ type="string",
+ description="Get only personal or generic email addresses. Allowed values: personal, generic",
+ ),
+ "seniority": ParameterDef(
+ type="string",
+ description="Get only email addresses for people with the selected seniority level(s). Comma-separated values: junior, senior, executive",
+ ),
+ "department": ParameterDef(
+ type="string",
+ description="Get only email addresses for people working in the selected department(s). Comma-separated values: executive, it, finance, management, sales, legal, support, hr, marketing, communication, education, design, health, operations",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="email_count",
+ description="Get the number of email addresses Hunter has for one domain or company",
+ parameters={
+ "domain": ParameterDef(
+ type="string",
+ description="Domain name from which you want to find the email addresses. For example, 'stripe.com'. Either domain or company must be provided",
+ ),
+ "company": ParameterDef(
+ type="string",
+ description="The company name from which you want to find the email addresses. For example, 'stripe'. Either domain or company must be provided",
+ ),
+ "type": ParameterDef(
+ type="string",
+ description="Get only personal or generic email addresses. Allowed values: personal, generic",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="email_finder",
+ description="Find the most likely email address from a domain name, a first name and a last name",
+ parameters={
+ "domain": ParameterDef(
+ type="string",
+ description="Domain name from which you want to find the email addresses. For example, 'stripe.com'. Either domain or company must be provided",
+ ),
+ "company": ParameterDef(
+ type="string",
+ description="The company name from which you want to find the email addresses. For example, 'stripe'. Either domain or company must be provided",
+ ),
+ "first_name": ParameterDef(
+ type="string",
+ description="The person's first name",
+ required=True,
+ ),
+ "last_name": ParameterDef(
+ type="string",
+ description="The person's last name",
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="email_verifier",
+ description="Check the deliverability of a given email address, verify if it has been found in Hunter's database, and return their sources",
+ parameters={
+ "email": ParameterDef(
+ type="string",
+ description="The email address you want to verify",
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="get_lead",
+ description="Retrieve one of your leads by ID",
+ parameters={
+ "lead_id": ParameterDef(
+ type="string",
+ description="The unique identifier of the lead",
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="get_leads_list",
+ description="Retrieves all the fields of a leads list, including its leads",
+ parameters={
+ "leads_list_id": ParameterDef(
+ type="string",
+ description="Identifier of the leads list to retrieve",
+ required=True,
+ ),
+ "limit": ParameterDef(
+ type="integer",
+ description="A limit on the number of leads to be returned. Limit can range between 1 and 100",
+ default=100,
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="list_leads",
+ description="List all your leads with comprehensive filtering options",
+ parameters={
+ "leads_list_id": ParameterDef(
+ type="string",
+ description="Only returns the leads belonging to this list",
+ ),
+ "email": ParameterDef(
+ type="string",
+ description="Filter leads by email",
+ ),
+ "first_name": ParameterDef(
+ type="string",
+ description="Filter leads by first name",
+ ),
+ "last_name": ParameterDef(
+ type="string",
+ description="Filter leads by last name",
+ ),
+ "position": ParameterDef(
+ type="string",
+ description="Filter leads by position",
+ ),
+ "company": ParameterDef(
+ type="string",
+ description="Filter leads by company",
+ ),
+ "industry": ParameterDef(
+ type="string",
+ description="Filter leads by industry",
+ ),
+ "website": ParameterDef(
+ type="string",
+ description="Filter leads by website",
+ ),
+ "country_code": ParameterDef(
+ type="string",
+ description="Filter leads by country code (ISO 3166-1 alpha-2)",
+ ),
+ "company_size": ParameterDef(
+ type="string",
+ description="Filter leads by company size",
+ ),
+ "source": ParameterDef(
+ type="string",
+ description="Filter leads by source",
+ ),
+ "twitter": ParameterDef(
+ type="string",
+ description="Filter leads by Twitter handle",
+ ),
+ "linkedin_url": ParameterDef(
+ type="string",
+ description="Filter leads by LinkedIn URL",
+ ),
+ "phone_number": ParameterDef(
+ type="string",
+ description="Filter leads by phone number",
+ ),
+ "sync_status": ParameterDef(
+ type="string",
+ description="Filter by synchronization status. Allowed values: pending, error, success",
+ ),
+ "sending_status": ParameterDef(
+ type="string",
+ description="Filter by sending status. Comma-separated values: clicked, opened, sent, pending, error, bounced, unsubscribed, replied",
+ ),
+ "verification_status": ParameterDef(
+ type="string",
+ description="Filter by verification status. Comma-separated values: accept_all, disposable, invalid, unknown, valid, webmail, pending",
+ ),
+ "last_activity_at": ParameterDef(
+ type="string",
+ description="Filter by last activity. Allowed values: * (any), ~ (unset)",
+ ),
+ "last_contacted_at": ParameterDef(
+ type="string",
+ description="Filter by last contact date. Allowed values: * (any), ~ (unset)",
+ ),
+ "query": ParameterDef(
+ type="string",
+ description="Only returns leads with First Name, Last Name, or Email matching the query",
+ ),
+ "limit": ParameterDef(
+ type="integer",
+ description="A limit on the number of leads to be returned. Limit can range between 1 and 1000",
+ default=100,
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="list_leads_lists",
+ description="List all your leads lists, sorted with the most recent first",
+ parameters={
+ "limit": ParameterDef(
+ type="integer",
+ description="A limit on the number of lists to be returned. Limit can range between 1 and 100",
+ default=100,
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="update_lead",
+ description="Update an existing lead in your Hunter account",
+ parameters={
+ "lead_id": ParameterDef(
+ type="string",
+ description="The unique identifier of the lead",
+ required=True,
+ ),
+ "email": ParameterDef(
+ type="string",
+ description="The email address of the lead",
+ ),
+ "first_name": ParameterDef(
+ type="string",
+ description="The person's first name",
+ ),
+ "last_name": ParameterDef(
+ type="string",
+ description="The person's last name",
+ ),
+ "position": ParameterDef(
+ type="string",
+ description="The person's position in the company",
+ ),
+ "company": ParameterDef(
+ type="string",
+ description="The company name",
+ ),
+ "website": ParameterDef(
+ type="string",
+ description="The website URL of the company",
+ ),
+ "phone_number": ParameterDef(
+ type="string",
+ description="The person's phone number",
+ ),
+ },
+ ),
+ ],
+ auth_schemas=[
+ ApiKeyAuthSchema(
+ display_name="API Key Authentication",
+ description="Authenticate using your Hunter API key",
+ setup_instructions=[
+ "Go to https://hunter.io and sign in",
+ "Navigate to your account API settings at https://hunter.io/api-keys",
+ "Copy your API key",
+ "Paste the API key below",
+ ],
+ setup_environment_variables=[
+ EnvVar(
+ name="HUNTER_API_KEY",
+ display_name="Hunter API Key",
+ description="Your Hunter API key from hunter.io/api-keys",
+ required=True,
+ sensitive=True,
+ sample_format="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ about_url="https://hunter.io/api-keys",
+ ),
+ ],
+ test_endpoint=TestEndpoint(
+ url="https://api.hunter.io/v2/account",
+ method="GET",
+ headers={},
+ params={"api_key": "{api_key}"},
+ success_indicators=SuccessIndicators(
+ status_codes=[200],
+ response_fields=["data"],
+ ),
+ cost_level="free",
+ description="Validates the API key by fetching account information",
+ ),
+ ),
+ ],
+)
diff --git a/src/modulex_integrations/tools/hunter/outputs.py b/src/modulex_integrations/tools/hunter/outputs.py
new file mode 100644
index 0000000..ab38f32
--- /dev/null
+++ b/src/modulex_integrations/tools/hunter/outputs.py
@@ -0,0 +1,157 @@
+"""Pydantic response models for the hunter integration's @tool functions."""
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field
+
+__all__ = [
+ "AccountInformationOutput",
+ "CombinedEnrichmentOutput",
+ "CreateLeadOutput",
+ "DeleteLeadOutput",
+ "DomainSearchOutput",
+ "EmailCountOutput",
+ "EmailFinderOutput",
+ "EmailVerifierOutput",
+ "GetLeadOutput",
+ "GetLeadsListOutput",
+ "ListLeadsListsOutput",
+ "ListLeadsOutput",
+ "UpdateLeadOutput",
+]
+
+
+class _Base(BaseModel):
+ """Shared config for every output model in this integration."""
+
+ model_config = ConfigDict(extra="forbid")
+
+
+class AccountInformationOutput(_Base):
+ success: bool
+ error: str | None = None
+ email: str | None = None
+ first_name: str | None = None
+ last_name: str | None = None
+ plan_name: str | None = None
+ plan_level: int | None = None
+ reset_date: str | None = None
+ team_id: int | None = None
+ calls_used: int | None = None
+ calls_available: int | None = None
+
+
+class CombinedEnrichmentOutput(_Base):
+ success: bool
+ error: str | None = None
+ data: dict[str, Any] | None = None
+
+
+class CreateLeadOutput(_Base):
+ success: bool
+ error: str | None = None
+ id: int | None = None
+ email: str | None = None
+ first_name: str | None = None
+ last_name: str | None = None
+
+
+class DeleteLeadOutput(_Base):
+ success: bool
+ error: str | None = None
+
+
+class DomainSearchOutput(_Base):
+ success: bool
+ error: str | None = None
+ domain: str | None = None
+ disposable: bool | None = None
+ webmail: bool | None = None
+ accept_all: bool | None = None
+ pattern: str | None = None
+ organization: str | None = None
+ emails: list[dict[str, Any]] = Field(default_factory=list)
+ total_results: int | None = None
+
+
+class EmailCountOutput(_Base):
+ success: bool
+ error: str | None = None
+ total: int | None = None
+ personal_emails: int | None = None
+ generic_emails: int | None = None
+ department: dict[str, Any] | None = None
+
+
+class EmailFinderOutput(_Base):
+ success: bool
+ error: str | None = None
+ email: str | None = None
+ first_name: str | None = None
+ last_name: str | None = None
+ score: int | None = None
+ domain: str | None = None
+ accept_all: bool | None = None
+ position: str | None = None
+ twitter: str | None = None
+ linkedin_url: str | None = None
+ phone_number: str | None = None
+ company: str | None = None
+ sources: list[dict[str, Any]] = Field(default_factory=list)
+
+
+class EmailVerifierOutput(_Base):
+ success: bool
+ error: str | None = None
+ status: str | None = None
+ result: str | None = None
+ score: int | None = None
+ email: str | None = None
+ regexp: bool | None = None
+ gibberish: bool | None = None
+ disposable: bool | None = None
+ webmail: bool | None = None
+ mx_records: bool | None = None
+ smtp_server: bool | None = None
+ smtp_check: bool | None = None
+ accept_all: bool | None = None
+ block: bool | None = None
+ sources: list[dict[str, Any]] = Field(default_factory=list)
+
+
+class GetLeadOutput(_Base):
+ success: bool
+ error: str | None = None
+ id: int | None = None
+ email: str | None = None
+ first_name: str | None = None
+ last_name: str | None = None
+ position: str | None = None
+ company: str | None = None
+
+
+class GetLeadsListOutput(_Base):
+ success: bool
+ error: str | None = None
+ id: int | None = None
+ name: str | None = None
+ leads: list[dict[str, Any]] = Field(default_factory=list)
+
+
+class ListLeadsListsOutput(_Base):
+ success: bool
+ error: str | None = None
+ leads_lists: list[dict[str, Any]] = Field(default_factory=list)
+
+
+class ListLeadsOutput(_Base):
+ success: bool
+ error: str | None = None
+ leads: list[dict[str, Any]] = Field(default_factory=list)
+ total: int | None = None
+
+
+class UpdateLeadOutput(_Base):
+ success: bool
+ error: str | None = None
diff --git a/src/modulex_integrations/tools/hunter/tests/__init__.py b/src/modulex_integrations/tools/hunter/tests/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/modulex_integrations/tools/hunter/tests/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/modulex_integrations/tools/hunter/tests/test_hunter.py b/src/modulex_integrations/tools/hunter/tests/test_hunter.py
new file mode 100644
index 0000000..5879a40
--- /dev/null
+++ b/src/modulex_integrations/tools/hunter/tests/test_hunter.py
@@ -0,0 +1,387 @@
+"""Happy-path tests for every hunter @tool, plus a manifest sanity check."""
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from modulex_integrations.tools.hunter import (
+ TOOLS,
+ account_information,
+ combined_enrichment,
+ create_lead,
+ delete_lead,
+ domain_search,
+ email_count,
+ email_finder,
+ email_verifier,
+ get_lead,
+ get_leads_list,
+ list_leads,
+ list_leads_lists,
+ manifest,
+ update_lead,
+)
+from modulex_integrations.tools.hunter.outputs import (
+ AccountInformationOutput,
+ CombinedEnrichmentOutput,
+ CreateLeadOutput,
+ DeleteLeadOutput,
+ DomainSearchOutput,
+ EmailCountOutput,
+ EmailFinderOutput,
+ EmailVerifierOutput,
+ GetLeadOutput,
+ GetLeadsListOutput,
+ ListLeadsListsOutput,
+ ListLeadsOutput,
+ UpdateLeadOutput,
+)
+
+API = "https://api.hunter.io/v2"
+
+_API_KEY = "fake-api-key"
+
+
+def _args(**extra: Any) -> dict[str, Any]:
+ return dict(api_key=_API_KEY, **extra)
+
+
+# --- Manifest sanity --------------------------------------------------------
+
+
+class TestManifest:
+ def test_manifest_exposes_13_actions(self) -> None:
+ assert len(manifest.actions) == 13
+
+ def test_manifest_actions_match_tools_tuple(self) -> None:
+ assert {a.name for a in manifest.actions} == {t.name for t in TOOLS}
+
+ def test_manifest_has_api_key_auth(self) -> None:
+ assert {a.auth_type for a in manifest.auth_schemas} == {"api_key"}
+
+
+# --- Per-action happy-path tests -------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_account_information(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/account?api_key={_API_KEY}",
+ json={
+ "data": {
+ "email": "user@example.com",
+ "first_name": "John",
+ "last_name": "Doe",
+ "plan_name": "Free",
+ "plan_level": 0,
+ "reset_date": "2026-06-01",
+ "team_id": 1,
+ "calls": {"used": 10, "available": 50},
+ }
+ },
+ )
+
+ result_dict = await account_information.ainvoke(_args())
+
+ assert isinstance(result_dict, dict)
+ result = AccountInformationOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.email == "user@example.com"
+ assert result.calls_used == 10
+
+
+@pytest.mark.asyncio
+async def test_combined_enrichment(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/combined/find?api_key={_API_KEY}&email=test%40example.com",
+ json={
+ "data": {
+ "person": {"first_name": "John"},
+ "company": {"name": "Example"},
+ }
+ },
+ )
+
+ result_dict = await combined_enrichment.ainvoke(_args(email="test@example.com"))
+
+ assert isinstance(result_dict, dict)
+ result = CombinedEnrichmentOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.data is not None
+
+
+@pytest.mark.asyncio
+async def test_create_lead(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/leads?api_key={_API_KEY}",
+ json={
+ "data": {
+ "id": 123,
+ "email": "lead@example.com",
+ "first_name": "Jane",
+ "last_name": "Smith",
+ }
+ },
+ )
+
+ result_dict = await create_lead.ainvoke(_args(email="lead@example.com"))
+
+ assert isinstance(result_dict, dict)
+ result = CreateLeadOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.id == 123
+
+
+@pytest.mark.asyncio
+async def test_delete_lead(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="DELETE",
+ url=f"{API}/leads/456?api_key={_API_KEY}",
+ status_code=204,
+ content=b"",
+ )
+
+ result_dict = await delete_lead.ainvoke(_args(lead_id="456"))
+
+ assert isinstance(result_dict, dict)
+ result = DeleteLeadOutput.model_validate(result_dict)
+ assert result.success is True
+
+
+@pytest.mark.asyncio
+async def test_domain_search(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/domain-search?api_key={_API_KEY}&domain=stripe.com&limit=10",
+ json={
+ "data": {
+ "domain": "stripe.com",
+ "disposable": False,
+ "webmail": False,
+ "accept_all": False,
+ "pattern": "{first}",
+ "organization": "Stripe",
+ "emails": [{"value": "john@stripe.com", "type": "personal"}],
+ },
+ "meta": {"results": 1, "limit": 10, "offset": 0},
+ },
+ )
+
+ result_dict = await domain_search.ainvoke(_args(domain="stripe.com", limit=10))
+
+ assert isinstance(result_dict, dict)
+ result = DomainSearchOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.domain == "stripe.com"
+ assert len(result.emails) == 1
+
+
+@pytest.mark.asyncio
+async def test_email_count(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/email-count?api_key={_API_KEY}&domain=stripe.com",
+ json={
+ "data": {
+ "total": 100,
+ "personal_emails": 80,
+ "generic_emails": 20,
+ "department": {},
+ }
+ },
+ )
+
+ result_dict = await email_count.ainvoke(_args(domain="stripe.com"))
+
+ assert isinstance(result_dict, dict)
+ result = EmailCountOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.total == 100
+
+
+@pytest.mark.asyncio
+async def test_email_finder(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/email-finder?api_key={_API_KEY}&domain=stripe.com&first_name=John&last_name=Doe",
+ json={
+ "data": {
+ "email": "john.doe@stripe.com",
+ "first_name": "John",
+ "last_name": "Doe",
+ "score": 92,
+ "domain": "stripe.com",
+ "accept_all": False,
+ "position": "Engineer",
+ "twitter": None,
+ "linkedin_url": None,
+ "phone_number": None,
+ "company": "Stripe",
+ "sources": [],
+ }
+ },
+ )
+
+ result_dict = await email_finder.ainvoke(
+ _args(first_name="John", last_name="Doe", domain="stripe.com")
+ )
+
+ assert isinstance(result_dict, dict)
+ result = EmailFinderOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.email == "john.doe@stripe.com"
+ assert result.score == 92
+
+
+@pytest.mark.asyncio
+async def test_email_verifier(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/email-verifier?api_key={_API_KEY}&email=test%40example.com",
+ json={
+ "data": {
+ "status": "valid",
+ "result": "deliverable",
+ "score": 95,
+ "email": "test@example.com",
+ "regexp": True,
+ "gibberish": False,
+ "disposable": False,
+ "webmail": False,
+ "mx_records": True,
+ "smtp_server": True,
+ "smtp_check": True,
+ "accept_all": False,
+ "block": False,
+ "sources": [],
+ }
+ },
+ )
+
+ result_dict = await email_verifier.ainvoke(_args(email="test@example.com"))
+
+ assert isinstance(result_dict, dict)
+ result = EmailVerifierOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.status == "valid"
+ assert result.score == 95
+
+
+@pytest.mark.asyncio
+async def test_get_lead(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/leads/789?api_key={_API_KEY}",
+ json={
+ "data": {
+ "id": 789,
+ "email": "lead@example.com",
+ "first_name": "Alice",
+ "last_name": "Johnson",
+ "position": "CTO",
+ "company": "Acme",
+ }
+ },
+ )
+
+ result_dict = await get_lead.ainvoke(_args(lead_id="789"))
+
+ assert isinstance(result_dict, dict)
+ result = GetLeadOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.id == 789
+
+
+@pytest.mark.asyncio
+async def test_get_leads_list(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/leads_lists/10?api_key={_API_KEY}&limit=20",
+ json={
+ "data": {
+ "id": 10,
+ "name": "My List",
+ "leads": [{"id": 1, "email": "a@b.com"}],
+ }
+ },
+ )
+
+ result_dict = await get_leads_list.ainvoke(_args(leads_list_id="10", limit=20))
+
+ assert isinstance(result_dict, dict)
+ result = GetLeadsListOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.name == "My List"
+ assert len(result.leads) == 1
+
+
+@pytest.mark.asyncio
+async def test_list_leads(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/leads?api_key={_API_KEY}&limit=10",
+ json={
+ "data": {
+ "leads": [{"id": 1, "email": "a@b.com"}],
+ },
+ "meta": {"total": 1, "limit": 10, "offset": 0},
+ },
+ )
+
+ result_dict = await list_leads.ainvoke(_args(limit=10))
+
+ assert isinstance(result_dict, dict)
+ result = ListLeadsOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.leads) == 1
+ assert result.total == 1
+
+
+@pytest.mark.asyncio
+async def test_list_leads_lists(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/leads_lists?api_key={_API_KEY}&limit=10",
+ json={
+ "data": {
+ "leads_lists": [{"id": 1, "name": "List A"}],
+ }
+ },
+ )
+
+ result_dict = await list_leads_lists.ainvoke(_args(limit=10))
+
+ assert isinstance(result_dict, dict)
+ result = ListLeadsListsOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.leads_lists) == 1
+
+
+@pytest.mark.asyncio
+async def test_update_lead(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="PUT",
+ url=f"{API}/leads/789?api_key={_API_KEY}",
+ status_code=200,
+ json={},
+ )
+
+ result_dict = await update_lead.ainvoke(
+ _args(lead_id="789", first_name="Updated")
+ )
+
+ assert isinstance(result_dict, dict)
+ result = UpdateLeadOutput.model_validate(result_dict)
+ assert result.success is True
+
+
+@pytest.mark.asyncio
+async def test_account_information_validates_empty_api_key() -> None:
+ result_dict = await account_information.ainvoke({"api_key": ""})
+ result = AccountInformationOutput.model_validate(result_dict)
+ assert result.success is False
+ assert "API key" in (result.error or "")
diff --git a/src/modulex_integrations/tools/hunter/tools.py b/src/modulex_integrations/tools/hunter/tools.py
new file mode 100644
index 0000000..5834c90
--- /dev/null
+++ b/src/modulex_integrations/tools/hunter/tools.py
@@ -0,0 +1,815 @@
+"""Hunter LangChain @tool functions."""
+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.hunter.outputs import (
+ AccountInformationOutput,
+ CombinedEnrichmentOutput,
+ CreateLeadOutput,
+ DeleteLeadOutput,
+ DomainSearchOutput,
+ EmailCountOutput,
+ EmailFinderOutput,
+ EmailVerifierOutput,
+ GetLeadOutput,
+ GetLeadsListOutput,
+ ListLeadsListsOutput,
+ ListLeadsOutput,
+ UpdateLeadOutput,
+)
+
+__all__ = [
+ "account_information",
+ "combined_enrichment",
+ "create_lead",
+ "delete_lead",
+ "domain_search",
+ "email_count",
+ "email_finder",
+ "email_verifier",
+ "get_lead",
+ "get_leads_list",
+ "list_leads",
+ "list_leads_lists",
+ "update_lead",
+]
+
+_BASE_URL = "https://api.hunter.io/v2"
+
+
+def _params(api_key: str, **extra: Any) -> dict[str, Any]:
+ """Build query params with the API key included."""
+ p: dict[str, Any] = {"api_key": api_key}
+ for k, v in extra.items():
+ if v is not None:
+ p[k] = v
+ return p
+
+
+# --- Input schemas --------------------------------------------------------
+
+
+class AccountInformationInput(BaseModel):
+ api_key: str = Field(description="Hunter API key")
+
+
+class CombinedEnrichmentInput(BaseModel):
+ email: str = Field(description="The email address you want to find information about")
+ api_key: str = Field(description="Hunter API key")
+
+
+class CreateLeadInput(BaseModel):
+ email: str = Field(description="The email address of the lead")
+ api_key: str = Field(description="Hunter API key")
+ first_name: str | None = Field(default=None, description="The first name of the lead")
+ last_name: str | None = Field(default=None, description="The last name of the lead")
+ position: str | None = Field(default=None, description="The job title of the lead")
+ company: str | None = Field(default=None, description="The name of the company the lead is working in")
+ company_industry: str | None = Field(default=None, description="The sector of the company")
+ company_size: str | None = Field(default=None, description="The size of the company")
+ confidence_score: int | None = Field(default=None, description="Probability the email address is correct, between 0 and 100")
+ website: str | None = Field(default=None, description="The domain name of the company")
+ country_code: str | None = Field(default=None, description="The country of the lead (ISO 3166-1 alpha-2)")
+ linkedin_url: str | None = Field(default=None, description="The public LinkedIn profile URL")
+ phone_number: str | None = Field(default=None, description="The phone number of the lead")
+ twitter: str | None = Field(default=None, description="The Twitter handle of the lead")
+ notes: str | None = Field(default=None, description="Personal notes about the lead")
+ source: str | None = Field(default=None, description="The source where the lead has been found")
+ leads_list_id: str | None = Field(default=None, description="The identifier of the list the lead belongs to")
+
+
+class DeleteLeadInput(BaseModel):
+ lead_id: str = Field(description="The unique identifier of the lead")
+ api_key: str = Field(description="Hunter API key")
+
+
+class DomainSearchInput(BaseModel):
+ api_key: str = Field(description="Hunter API key")
+ domain: str | None = Field(default=None, description="Domain name to search. Either domain or company must be provided")
+ company: str | None = Field(default=None, description="Company name to search. Either domain or company must be provided")
+ limit: int = Field(default=100, description="Max number of email addresses to return")
+ type: str | None = Field(default=None, description="Get only personal or generic email addresses. Allowed values: personal, generic")
+ seniority: str | None = Field(default=None, description="Seniority level(s), comma-separated: junior, senior, executive")
+ department: str | None = Field(default=None, description="Department(s), comma-separated: executive, it, finance, management, sales, legal, support, hr, marketing, communication, education, design, health, operations")
+
+
+class EmailCountInput(BaseModel):
+ api_key: str = Field(description="Hunter API key")
+ domain: str | None = Field(default=None, description="Domain name. Either domain or company must be provided")
+ company: str | None = Field(default=None, description="Company name. Either domain or company must be provided")
+ type: str | None = Field(default=None, description="Get only personal or generic email addresses. Allowed values: personal, generic")
+
+
+class EmailFinderInput(BaseModel):
+ first_name: str = Field(description="The person's first name")
+ last_name: str = Field(description="The person's last name")
+ api_key: str = Field(description="Hunter API key")
+ domain: str | None = Field(default=None, description="Domain name. Either domain or company must be provided")
+ company: str | None = Field(default=None, description="Company name. Either domain or company must be provided")
+
+
+class EmailVerifierInput(BaseModel):
+ email: str = Field(description="The email address you want to verify")
+ api_key: str = Field(description="Hunter API key")
+
+
+class GetLeadInput(BaseModel):
+ lead_id: str = Field(description="The unique identifier of the lead")
+ api_key: str = Field(description="Hunter API key")
+
+
+class GetLeadsListInput(BaseModel):
+ leads_list_id: str = Field(description="Identifier of the leads list to retrieve")
+ api_key: str = Field(description="Hunter API key")
+ limit: int = Field(default=100, description="A limit on the number of leads to be returned (1-100)")
+
+
+class ListLeadsInput(BaseModel):
+ api_key: str = Field(description="Hunter API key")
+ limit: int = Field(default=100, description="A limit on the number of leads to be returned (1-1000)")
+ leads_list_id: str | None = Field(default=None, description="Only returns leads belonging to this list")
+ email: str | None = Field(default=None, description="Filter leads by email")
+ first_name: str | None = Field(default=None, description="Filter leads by first name")
+ last_name: str | None = Field(default=None, description="Filter leads by last name")
+ position: str | None = Field(default=None, description="Filter leads by position")
+ company: str | None = Field(default=None, description="Filter leads by company")
+ industry: str | None = Field(default=None, description="Filter leads by industry")
+ website: str | None = Field(default=None, description="Filter leads by website")
+ country_code: str | None = Field(default=None, description="Filter leads by country code (ISO 3166-1 alpha-2)")
+ company_size: str | None = Field(default=None, description="Filter leads by company size")
+ source: str | None = Field(default=None, description="Filter leads by source")
+ twitter: str | None = Field(default=None, description="Filter leads by Twitter handle")
+ linkedin_url: str | None = Field(default=None, description="Filter leads by LinkedIn URL")
+ phone_number: str | None = Field(default=None, description="Filter leads by phone number")
+ sync_status: str | None = Field(default=None, description="Filter by synchronization status: pending, error, success")
+ sending_status: str | None = Field(default=None, description="Filter by sending status, comma-separated: clicked, opened, sent, pending, error, bounced, unsubscribed, replied")
+ verification_status: str | None = Field(default=None, description="Filter by verification status, comma-separated: accept_all, disposable, invalid, unknown, valid, webmail, pending")
+ last_activity_at: str | None = Field(default=None, description="Filter by last activity: * (any), ~ (unset)")
+ last_contacted_at: str | None = Field(default=None, description="Filter by last contact date: * (any), ~ (unset)")
+ query: str | None = Field(default=None, description="Search leads by First Name, Last Name, or Email")
+
+
+class ListLeadsListsInput(BaseModel):
+ api_key: str = Field(description="Hunter API key")
+ limit: int = Field(default=100, description="A limit on the number of lists to be returned (1-100)")
+
+
+class UpdateLeadInput(BaseModel):
+ lead_id: str = Field(description="The unique identifier of the lead")
+ api_key: str = Field(description="Hunter API key")
+ email: str | None = Field(default=None, description="The email address of the lead")
+ first_name: str | None = Field(default=None, description="The person's first name")
+ last_name: str | None = Field(default=None, description="The person's last name")
+ position: str | None = Field(default=None, description="The person's position in the company")
+ company: str | None = Field(default=None, description="The company name")
+ website: str | None = Field(default=None, description="The website URL of the company")
+ phone_number: str | None = Field(default=None, description="The person's phone number")
+
+
+# --- @tool functions ------------------------------------------------------
+
+
+@tool(args_schema=AccountInformationInput)
+@serialize_pydantic_return
+async def account_information(
+ api_key: str,
+) -> AccountInformationOutput:
+ """Get information about your Hunter account."""
+ if not api_key or not api_key.strip():
+ return AccountInformationOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.get(
+ f"{_BASE_URL}/account",
+ params=_params(api_key),
+ )
+ if response.status_code != 200:
+ return AccountInformationOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json().get("data", {})
+ except httpx.TimeoutException:
+ return AccountInformationOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return AccountInformationOutput(success=False, error=f"Call failed: {exc}")
+
+ calls = data.get("calls", {})
+ return AccountInformationOutput(
+ success=True,
+ email=data.get("email"),
+ first_name=data.get("first_name"),
+ last_name=data.get("last_name"),
+ plan_name=data.get("plan_name"),
+ plan_level=data.get("plan_level"),
+ reset_date=data.get("reset_date"),
+ team_id=data.get("team_id"),
+ calls_used=calls.get("used"),
+ calls_available=calls.get("available"),
+ )
+
+
+@tool(args_schema=CombinedEnrichmentInput)
+@serialize_pydantic_return
+async def combined_enrichment(
+ email: str,
+ api_key: str,
+) -> CombinedEnrichmentOutput:
+ """Returns all the information associated with an email address and its domain name."""
+ if not api_key or not api_key.strip():
+ return CombinedEnrichmentOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.get(
+ f"{_BASE_URL}/combined/find",
+ params=_params(api_key, email=email),
+ )
+ if response.status_code != 200:
+ return CombinedEnrichmentOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json().get("data", {})
+ except httpx.TimeoutException:
+ return CombinedEnrichmentOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return CombinedEnrichmentOutput(success=False, error=f"Call failed: {exc}")
+
+ return CombinedEnrichmentOutput(success=True, data=data)
+
+
+@tool(args_schema=CreateLeadInput)
+@serialize_pydantic_return
+async def create_lead(
+ email: str,
+ api_key: str,
+ first_name: str | None = None,
+ last_name: str | None = None,
+ position: str | None = None,
+ company: str | None = None,
+ company_industry: str | None = None,
+ company_size: str | None = None,
+ confidence_score: int | None = None,
+ website: str | None = None,
+ country_code: str | None = None,
+ linkedin_url: str | None = None,
+ phone_number: str | None = None,
+ twitter: str | None = None,
+ notes: str | None = None,
+ source: str | None = None,
+ leads_list_id: str | None = None,
+) -> CreateLeadOutput:
+ """Create a new lead in your Hunter account."""
+ if not api_key or not api_key.strip():
+ return CreateLeadOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+ body: dict[str, Any] = {"email": email}
+ for field_name, value in [
+ ("first_name", first_name),
+ ("last_name", last_name),
+ ("position", position),
+ ("company", company),
+ ("company_industry", company_industry),
+ ("company_size", company_size),
+ ("confidence_score", confidence_score),
+ ("website", website),
+ ("country_code", country_code),
+ ("linkedin_url", linkedin_url),
+ ("phone_number", phone_number),
+ ("twitter", twitter),
+ ("notes", notes),
+ ("source", source),
+ ("leads_list_id", leads_list_id),
+ ]:
+ if value is not None:
+ body[field_name] = value
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.post(
+ f"{_BASE_URL}/leads",
+ params=_params(api_key),
+ json=body,
+ )
+ if response.status_code not in (200, 201):
+ return CreateLeadOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json().get("data", {})
+ except httpx.TimeoutException:
+ return CreateLeadOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return CreateLeadOutput(success=False, error=f"Call failed: {exc}")
+
+ return CreateLeadOutput(
+ success=True,
+ id=data.get("id"),
+ email=data.get("email"),
+ first_name=data.get("first_name"),
+ last_name=data.get("last_name"),
+ )
+
+
+@tool(args_schema=DeleteLeadInput)
+@serialize_pydantic_return
+async def delete_lead(
+ lead_id: str,
+ api_key: str,
+) -> DeleteLeadOutput:
+ """Delete an existing lead from your Hunter account."""
+ if not api_key or not api_key.strip():
+ return DeleteLeadOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.delete(
+ f"{_BASE_URL}/leads/{lead_id}",
+ params=_params(api_key),
+ )
+ if response.status_code not in (200, 204):
+ return DeleteLeadOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ except httpx.TimeoutException:
+ return DeleteLeadOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return DeleteLeadOutput(success=False, error=f"Call failed: {exc}")
+
+ return DeleteLeadOutput(success=True)
+
+
+@tool(args_schema=DomainSearchInput)
+@serialize_pydantic_return
+async def domain_search(
+ api_key: str,
+ domain: str | None = None,
+ company: str | None = None,
+ limit: int = 100,
+ type: str | None = None,
+ seniority: str | None = None,
+ department: str | None = None,
+) -> DomainSearchOutput:
+ """Search all the email addresses corresponding to one website or company."""
+ if not api_key or not api_key.strip():
+ return DomainSearchOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+ if not domain and not company:
+ return DomainSearchOutput(
+ success=False,
+ error="Either domain or company must be provided.",
+ )
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.get(
+ f"{_BASE_URL}/domain-search",
+ params=_params(
+ api_key,
+ domain=domain,
+ company=company,
+ limit=limit,
+ type=type,
+ seniority=seniority,
+ department=department,
+ ),
+ )
+ if response.status_code != 200:
+ return DomainSearchOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ resp = response.json()
+ data = resp.get("data", {})
+ meta = resp.get("meta", {})
+ except httpx.TimeoutException:
+ return DomainSearchOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return DomainSearchOutput(success=False, error=f"Call failed: {exc}")
+
+ return DomainSearchOutput(
+ success=True,
+ domain=data.get("domain"),
+ disposable=data.get("disposable"),
+ webmail=data.get("webmail"),
+ accept_all=data.get("accept_all"),
+ pattern=data.get("pattern"),
+ organization=data.get("organization"),
+ emails=data.get("emails", []),
+ total_results=meta.get("results"),
+ )
+
+
+@tool(args_schema=EmailCountInput)
+@serialize_pydantic_return
+async def email_count(
+ api_key: str,
+ domain: str | None = None,
+ company: str | None = None,
+ type: str | None = None,
+) -> EmailCountOutput:
+ """Get the number of email addresses Hunter has for one domain or company."""
+ if not api_key or not api_key.strip():
+ return EmailCountOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+ if not domain and not company:
+ return EmailCountOutput(
+ success=False,
+ error="Either domain or company must be provided.",
+ )
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.get(
+ f"{_BASE_URL}/email-count",
+ params=_params(api_key, domain=domain, company=company, type=type),
+ )
+ if response.status_code != 200:
+ return EmailCountOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json().get("data", {})
+ except httpx.TimeoutException:
+ return EmailCountOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return EmailCountOutput(success=False, error=f"Call failed: {exc}")
+
+ return EmailCountOutput(
+ success=True,
+ total=data.get("total"),
+ personal_emails=data.get("personal_emails"),
+ generic_emails=data.get("generic_emails"),
+ department=data.get("department"),
+ )
+
+
+@tool(args_schema=EmailFinderInput)
+@serialize_pydantic_return
+async def email_finder(
+ first_name: str,
+ last_name: str,
+ api_key: str,
+ domain: str | None = None,
+ company: str | None = None,
+) -> EmailFinderOutput:
+ """Find the most likely email address from a domain name, a first name and a last name."""
+ if not api_key or not api_key.strip():
+ return EmailFinderOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+ if not domain and not company:
+ return EmailFinderOutput(
+ success=False,
+ error="Either domain or company must be provided.",
+ )
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.get(
+ f"{_BASE_URL}/email-finder",
+ params=_params(
+ api_key,
+ domain=domain,
+ company=company,
+ first_name=first_name,
+ last_name=last_name,
+ ),
+ )
+ if response.status_code != 200:
+ return EmailFinderOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json().get("data", {})
+ except httpx.TimeoutException:
+ return EmailFinderOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return EmailFinderOutput(success=False, error=f"Call failed: {exc}")
+
+ return EmailFinderOutput(
+ success=True,
+ email=data.get("email"),
+ first_name=data.get("first_name"),
+ last_name=data.get("last_name"),
+ score=data.get("score"),
+ domain=data.get("domain"),
+ accept_all=data.get("accept_all"),
+ position=data.get("position"),
+ twitter=data.get("twitter"),
+ linkedin_url=data.get("linkedin_url"),
+ phone_number=data.get("phone_number"),
+ company=data.get("company"),
+ sources=data.get("sources", []),
+ )
+
+
+@tool(args_schema=EmailVerifierInput)
+@serialize_pydantic_return
+async def email_verifier(
+ email: str,
+ api_key: str,
+) -> EmailVerifierOutput:
+ """Check the deliverability of a given email address, verify if it has been found in Hunter's database, and return their sources."""
+ if not api_key or not api_key.strip():
+ return EmailVerifierOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.get(
+ f"{_BASE_URL}/email-verifier",
+ params=_params(api_key, email=email),
+ )
+ if response.status_code != 200:
+ return EmailVerifierOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json().get("data", {})
+ except httpx.TimeoutException:
+ return EmailVerifierOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return EmailVerifierOutput(success=False, error=f"Call failed: {exc}")
+
+ return EmailVerifierOutput(
+ success=True,
+ status=data.get("status"),
+ result=data.get("result"),
+ score=data.get("score"),
+ email=data.get("email"),
+ regexp=data.get("regexp"),
+ gibberish=data.get("gibberish"),
+ disposable=data.get("disposable"),
+ webmail=data.get("webmail"),
+ mx_records=data.get("mx_records"),
+ smtp_server=data.get("smtp_server"),
+ smtp_check=data.get("smtp_check"),
+ accept_all=data.get("accept_all"),
+ block=data.get("block"),
+ sources=data.get("sources", []),
+ )
+
+
+@tool(args_schema=GetLeadInput)
+@serialize_pydantic_return
+async def get_lead(
+ lead_id: str,
+ api_key: str,
+) -> GetLeadOutput:
+ """Retrieve one of your leads by ID."""
+ if not api_key or not api_key.strip():
+ return GetLeadOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.get(
+ f"{_BASE_URL}/leads/{lead_id}",
+ params=_params(api_key),
+ )
+ if response.status_code != 200:
+ return GetLeadOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json().get("data", {})
+ except httpx.TimeoutException:
+ return GetLeadOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return GetLeadOutput(success=False, error=f"Call failed: {exc}")
+
+ return GetLeadOutput(
+ success=True,
+ id=data.get("id"),
+ email=data.get("email"),
+ first_name=data.get("first_name"),
+ last_name=data.get("last_name"),
+ position=data.get("position"),
+ company=data.get("company"),
+ )
+
+
+@tool(args_schema=GetLeadsListInput)
+@serialize_pydantic_return
+async def get_leads_list(
+ leads_list_id: str,
+ api_key: str,
+ limit: int = 100,
+) -> GetLeadsListOutput:
+ """Retrieves all the fields of a leads list, including its leads."""
+ if not api_key or not api_key.strip():
+ return GetLeadsListOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.get(
+ f"{_BASE_URL}/leads_lists/{leads_list_id}",
+ params=_params(api_key, limit=limit),
+ )
+ if response.status_code != 200:
+ return GetLeadsListOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json().get("data", {})
+ except httpx.TimeoutException:
+ return GetLeadsListOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return GetLeadsListOutput(success=False, error=f"Call failed: {exc}")
+
+ return GetLeadsListOutput(
+ success=True,
+ id=data.get("id"),
+ name=data.get("name"),
+ leads=data.get("leads", []),
+ )
+
+
+@tool(args_schema=ListLeadsInput)
+@serialize_pydantic_return
+async def list_leads(
+ api_key: str,
+ limit: int = 100,
+ leads_list_id: str | None = None,
+ email: str | None = None,
+ first_name: str | None = None,
+ last_name: str | None = None,
+ position: str | None = None,
+ company: str | None = None,
+ industry: str | None = None,
+ website: str | None = None,
+ country_code: str | None = None,
+ company_size: str | None = None,
+ source: str | None = None,
+ twitter: str | None = None,
+ linkedin_url: str | None = None,
+ phone_number: str | None = None,
+ sync_status: str | None = None,
+ sending_status: str | None = None,
+ verification_status: str | None = None,
+ last_activity_at: str | None = None,
+ last_contacted_at: str | None = None,
+ query: str | None = None,
+) -> ListLeadsOutput:
+ """List all your leads with comprehensive filtering options."""
+ if not api_key or not api_key.strip():
+ return ListLeadsOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.get(
+ f"{_BASE_URL}/leads",
+ params=_params(
+ api_key,
+ limit=limit,
+ leads_list_id=leads_list_id,
+ email=email,
+ first_name=first_name,
+ last_name=last_name,
+ position=position,
+ company=company,
+ industry=industry,
+ website=website,
+ country_code=country_code,
+ company_size=company_size,
+ source=source,
+ twitter=twitter,
+ linkedin_url=linkedin_url,
+ phone_number=phone_number,
+ sync_status=sync_status,
+ sending_status=sending_status,
+ verification_status=verification_status,
+ last_activity_at=last_activity_at,
+ last_contacted_at=last_contacted_at,
+ query=query,
+ ),
+ )
+ if response.status_code != 200:
+ return ListLeadsOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ resp = response.json()
+ data = resp.get("data", {})
+ meta = resp.get("meta", {})
+ except httpx.TimeoutException:
+ return ListLeadsOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return ListLeadsOutput(success=False, error=f"Call failed: {exc}")
+
+ return ListLeadsOutput(
+ success=True,
+ leads=data.get("leads", []),
+ total=meta.get("total"),
+ )
+
+
+@tool(args_schema=ListLeadsListsInput)
+@serialize_pydantic_return
+async def list_leads_lists(
+ api_key: str,
+ limit: int = 100,
+) -> ListLeadsListsOutput:
+ """List all your leads lists, sorted with the most recent first."""
+ if not api_key or not api_key.strip():
+ return ListLeadsListsOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.get(
+ f"{_BASE_URL}/leads_lists",
+ params=_params(api_key, limit=limit),
+ )
+ if response.status_code != 200:
+ return ListLeadsListsOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json().get("data", {})
+ except httpx.TimeoutException:
+ return ListLeadsListsOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return ListLeadsListsOutput(success=False, error=f"Call failed: {exc}")
+
+ return ListLeadsListsOutput(
+ success=True,
+ leads_lists=data.get("leads_lists", []),
+ )
+
+
+@tool(args_schema=UpdateLeadInput)
+@serialize_pydantic_return
+async def update_lead(
+ lead_id: str,
+ api_key: str,
+ email: str | None = None,
+ first_name: str | None = None,
+ last_name: str | None = None,
+ position: str | None = None,
+ company: str | None = None,
+ website: str | None = None,
+ phone_number: str | None = None,
+) -> UpdateLeadOutput:
+ """Update an existing lead in your Hunter account."""
+ if not api_key or not api_key.strip():
+ return UpdateLeadOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+ body: dict[str, Any] = {}
+ for field_name, value in [
+ ("email", email),
+ ("first_name", first_name),
+ ("last_name", last_name),
+ ("position", position),
+ ("company", company),
+ ("website", website),
+ ("phone_number", phone_number),
+ ]:
+ if value is not None:
+ body[field_name] = value
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.put(
+ f"{_BASE_URL}/leads/{lead_id}",
+ params=_params(api_key),
+ json=body,
+ )
+ if response.status_code not in (200, 204):
+ return UpdateLeadOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ except httpx.TimeoutException:
+ return UpdateLeadOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return UpdateLeadOutput(success=False, error=f"Call failed: {exc}")
+
+ return UpdateLeadOutput(success=True)
diff --git a/src/modulex_integrations/tools/livestorm/README.md b/src/modulex_integrations/tools/livestorm/README.md
new file mode 100644
index 0000000..d3df9ba
--- /dev/null
+++ b/src/modulex_integrations/tools/livestorm/README.md
@@ -0,0 +1,38 @@
+# Livestorm
+
+Video engagement platform for webinars and virtual events via the Livestorm REST API (`api.livestorm.co/v1`).
+
+## Authentication
+
+### OAuth2 Authentication (recommended)
+
+- Register an OAuth application at the [Livestorm developer portal](https://developers.livestorm.co).
+- Redirect URI: `https://api.modulex.dev/credentials/oauth2/callback`
+- Required env vars (custom app only):
+ - `LIVESTORM_OAUTH2_CLIENT_ID` — OAuth App Client ID
+ - `LIVESTORM_OAUTH2_CLIENT_SECRET` — OAuth App Client Secret
+- Scopes: none documented; the platform grants full API access upon authorization.
+
+## Tools
+
+| name | description | required params |
+| --- | --- | --- |
+| `create_event` | Create a new event | `owner_id`, `title` |
+| `get_event` | Retrieve a single event | `event_id` |
+| `list_attendees_from_event` | List all the people linked to all the sessions of an event | `event_id` |
+| `list_events` | List the events of your workspace | |
+| `list_sessions` | List all your event sessions | |
+| `register_someone_for_session` | Register a new participant for a session | `session_id` |
+| `update_event` | Update an event with its full list of attributes | `event_id`, `owner_id`, `title`, `slug`, `status`, `description`, `recording_enabled`, `chat_enabled`, `everyone_can_speak`, `detailed_registration_page_enabled`, `light_registration_page_enabled`, `recording_public`, `show_in_company_page`, `polls_enabled`, `questions_enabled` |
+
+Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fills in from the resolved OAuth credential.
+
+## Limits & Quotas
+
+- No publicly documented rate limits for the Livestorm API.
+- Pagination is applied automatically for list endpoints (page-based).
+- Error model: non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising.
+
+## Maintainer
+
+ModuleX core team.
diff --git a/src/modulex_integrations/tools/livestorm/__init__.py b/src/modulex_integrations/tools/livestorm/__init__.py
new file mode 100644
index 0000000..b979d46
--- /dev/null
+++ b/src/modulex_integrations/tools/livestorm/__init__.py
@@ -0,0 +1,33 @@
+"""Livestorm integration — discovered via the ``modulex.tools`` entry point."""
+from modulex_integrations.tools.livestorm.manifest import manifest
+from modulex_integrations.tools.livestorm.tools import (
+ create_event,
+ get_event,
+ list_attendees_from_event,
+ list_events,
+ list_sessions,
+ register_someone_for_session,
+ update_event,
+)
+
+TOOLS = (
+ create_event,
+ get_event,
+ list_attendees_from_event,
+ list_events,
+ list_sessions,
+ register_someone_for_session,
+ update_event,
+)
+
+__all__ = [
+ "TOOLS",
+ "create_event",
+ "get_event",
+ "list_attendees_from_event",
+ "list_events",
+ "list_sessions",
+ "manifest",
+ "register_someone_for_session",
+ "update_event",
+]
diff --git a/src/modulex_integrations/tools/livestorm/dependencies.toml b/src/modulex_integrations/tools/livestorm/dependencies.toml
new file mode 100644
index 0000000..6fface2
--- /dev/null
+++ b/src/modulex_integrations/tools/livestorm/dependencies.toml
@@ -0,0 +1,3 @@
+# Runtime dependencies for the livestorm integration.
+# CI assembles this into the root pyproject's [project.optional-dependencies].
+dependencies = []
diff --git a/src/modulex_integrations/tools/livestorm/manifest.py b/src/modulex_integrations/tools/livestorm/manifest.py
new file mode 100644
index 0000000..ed04dc2
--- /dev/null
+++ b/src/modulex_integrations/tools/livestorm/manifest.py
@@ -0,0 +1,299 @@
+"""Livestorm integration manifest."""
+from __future__ import annotations
+
+from modulex_integrations.schema import (
+ ActionDefinition,
+ EnvVar,
+ IntegrationManifest,
+ OAuth2AuthSchema,
+ OAuthConfig,
+ ParameterDef,
+ SuccessIndicators,
+ TestEndpoint,
+)
+
+__all__ = ["manifest"]
+
+
+manifest = IntegrationManifest(
+ name="livestorm",
+ display_name="Livestorm",
+ description="Video engagement platform for webinars and virtual events",
+ version="1.0.0",
+ author="ModuleX",
+ logo="modulex:livestorm-themed",
+ app_url="https://livestorm.co",
+ categories=["Marketing", "Webinars & Events"],
+ actions=[
+ ActionDefinition(
+ name="create_event",
+ description="Create a new event",
+ parameters={
+ "owner_id": ParameterDef(
+ type="string",
+ description="The ID of the user who owns the event",
+ required=True,
+ ),
+ "title": ParameterDef(
+ type="string",
+ description="The title of the event",
+ required=True,
+ ),
+ "slug": ParameterDef(
+ type="string",
+ description="The slug of the event",
+ ),
+ "status": ParameterDef(
+ type="string",
+ description="The status of the event: draft, published",
+ ),
+ "description": ParameterDef(
+ type="string",
+ description="The HTML description of the event",
+ ),
+ "recording_enabled": ParameterDef(
+ type="boolean",
+ description="Whether the event is recorded",
+ ),
+ "chat_enabled": ParameterDef(
+ type="boolean",
+ description="Whether the chat is enabled",
+ ),
+ "everyone_can_speak": ParameterDef(
+ type="boolean",
+ description="Whether everyone can speak",
+ ),
+ "detailed_registration_page_enabled": ParameterDef(
+ type="boolean",
+ description="Whether the detailed registration page is enabled",
+ ),
+ "light_registration_page_enabled": ParameterDef(
+ type="boolean",
+ description="Whether the light registration page is enabled",
+ ),
+ "recording_public": ParameterDef(
+ type="boolean",
+ description="Whether the recording is public",
+ ),
+ "show_in_company_page": ParameterDef(
+ type="boolean",
+ description="Whether the event is shown in the company page",
+ ),
+ "polls_enabled": ParameterDef(
+ type="boolean",
+ description="Whether the polls are enabled",
+ ),
+ "questions_enabled": ParameterDef(
+ type="boolean",
+ description="Whether the questions are enabled",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="get_event",
+ description="Retrieve a single event",
+ parameters={
+ "event_id": ParameterDef(
+ type="string",
+ description="The ID of the event",
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="list_attendees_from_event",
+ description="List all the people linked to all the sessions of an event",
+ parameters={
+ "event_id": ParameterDef(
+ type="string",
+ description="The ID of the event",
+ required=True,
+ ),
+ "role_filter": ParameterDef(
+ type="string",
+ description="Filter by role: participant, team_member",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="list_events",
+ description="List the events of your workspace",
+ parameters={
+ "title_filter": ParameterDef(
+ type="string",
+ description="Filter events by title",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="list_sessions",
+ description="List all your event sessions",
+ parameters={},
+ ),
+ ActionDefinition(
+ name="register_someone_for_session",
+ description="Register a new participant for a session",
+ parameters={
+ "session_id": ParameterDef(
+ type="string",
+ description="The ID of the session",
+ required=True,
+ ),
+ "referrer": ParameterDef(
+ type="string",
+ description="The referrer of the person registering",
+ ),
+ "utm_source": ParameterDef(
+ type="string",
+ description="The UTM source",
+ ),
+ "utm_medium": ParameterDef(
+ type="string",
+ description="The UTM medium",
+ ),
+ "utm_campaign": ParameterDef(
+ type="string",
+ description="The UTM campaign",
+ ),
+ "utm_term": ParameterDef(
+ type="string",
+ description="The UTM term",
+ ),
+ "utm_content": ParameterDef(
+ type="string",
+ description="The UTM content",
+ ),
+ "fields": ParameterDef(
+ type="object",
+ description="Registration fields as key-value pairs where key is the field ID and value is the field value",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="update_event",
+ description="Update an event with its full list of attributes",
+ parameters={
+ "event_id": ParameterDef(
+ type="string",
+ description="The ID of the event",
+ required=True,
+ ),
+ "owner_id": ParameterDef(
+ type="string",
+ description="The ID of the user who owns the event",
+ required=True,
+ ),
+ "title": ParameterDef(
+ type="string",
+ description="The title of the event",
+ required=True,
+ ),
+ "slug": ParameterDef(
+ type="string",
+ description="The slug of the event",
+ required=True,
+ ),
+ "status": ParameterDef(
+ type="string",
+ description="The status of the event: draft, published",
+ required=True,
+ ),
+ "description": ParameterDef(
+ type="string",
+ description="The HTML description of the event",
+ required=True,
+ ),
+ "recording_enabled": ParameterDef(
+ type="boolean",
+ description="Whether the event is recorded",
+ required=True,
+ ),
+ "chat_enabled": ParameterDef(
+ type="boolean",
+ description="Whether the chat is enabled",
+ required=True,
+ ),
+ "everyone_can_speak": ParameterDef(
+ type="boolean",
+ description="Whether everyone can speak",
+ required=True,
+ ),
+ "detailed_registration_page_enabled": ParameterDef(
+ type="boolean",
+ description="Whether the detailed registration page is enabled",
+ required=True,
+ ),
+ "light_registration_page_enabled": ParameterDef(
+ type="boolean",
+ description="Whether the light registration page is enabled",
+ required=True,
+ ),
+ "recording_public": ParameterDef(
+ type="boolean",
+ description="Whether the recording is public",
+ required=True,
+ ),
+ "show_in_company_page": ParameterDef(
+ type="boolean",
+ description="Whether the event is shown in the company page",
+ required=True,
+ ),
+ "polls_enabled": ParameterDef(
+ type="boolean",
+ description="Whether the polls are enabled",
+ required=True,
+ ),
+ "questions_enabled": ParameterDef(
+ type="boolean",
+ description="Whether the questions are enabled",
+ required=True,
+ ),
+ },
+ ),
+ ],
+ auth_schemas=[
+ OAuth2AuthSchema(
+ display_name="OAuth2 Authentication",
+ description="Connect using Livestorm OAuth (recommended)",
+ setup_environment_variables=[
+ EnvVar(
+ name="LIVESTORM_OAUTH2_CLIENT_ID",
+ display_name="Client ID",
+ description="Livestorm OAuth App Client ID",
+ required=True,
+ sensitive=False,
+ only_for_custom=True,
+ about_url="https://developers.livestorm.co",
+ ),
+ EnvVar(
+ name="LIVESTORM_OAUTH2_CLIENT_SECRET",
+ display_name="Client Secret",
+ description="Livestorm OAuth App Client Secret",
+ required=True,
+ sensitive=True,
+ only_for_custom=True,
+ about_url="https://developers.livestorm.co",
+ ),
+ ],
+ oauth_config=OAuthConfig(
+ auth_url="https://app.livestorm.co/oauth/authorize",
+ token_url="https://app.livestorm.co/oauth/token",
+ scopes=[],
+ ),
+ test_endpoint=TestEndpoint(
+ url="https://api.livestorm.co/v1/events",
+ method="GET",
+ headers={
+ "Authorization": "Bearer {access_token}",
+ "Accept": "application/vnd.api+json",
+ },
+ success_indicators=SuccessIndicators(
+ status_codes=[200],
+ response_fields=["data"],
+ ),
+ cost_level="free",
+ description="Validates OAuth token by listing events",
+ ),
+ ),
+ ],
+)
diff --git a/src/modulex_integrations/tools/livestorm/outputs.py b/src/modulex_integrations/tools/livestorm/outputs.py
new file mode 100644
index 0000000..b94c8a7
--- /dev/null
+++ b/src/modulex_integrations/tools/livestorm/outputs.py
@@ -0,0 +1,67 @@
+"""Pydantic response models for the livestorm integration's @tool functions."""
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field
+
+__all__ = [
+ "CreateEventOutput",
+ "GetEventOutput",
+ "ListAttendeesFromEventOutput",
+ "ListEventsOutput",
+ "ListSessionsOutput",
+ "RegisterSomeoneForSessionOutput",
+ "UpdateEventOutput",
+]
+
+
+class _Base(BaseModel):
+ """Shared config for every output model in this integration."""
+
+ model_config = ConfigDict(extra="forbid")
+
+
+# --- Per-action output models ------------------------------------------------
+
+
+class CreateEventOutput(_Base):
+ success: bool
+ error: str | None = None
+ data: dict[str, Any] | None = None
+
+
+class GetEventOutput(_Base):
+ success: bool
+ error: str | None = None
+ data: dict[str, Any] | None = None
+
+
+class ListAttendeesFromEventOutput(_Base):
+ success: bool
+ error: str | None = None
+ data: list[dict[str, Any]] = Field(default_factory=list)
+
+
+class ListEventsOutput(_Base):
+ success: bool
+ error: str | None = None
+ data: list[dict[str, Any]] = Field(default_factory=list)
+
+
+class ListSessionsOutput(_Base):
+ success: bool
+ error: str | None = None
+ data: list[dict[str, Any]] = Field(default_factory=list)
+
+
+class RegisterSomeoneForSessionOutput(_Base):
+ success: bool
+ error: str | None = None
+ data: dict[str, Any] | None = None
+
+
+class UpdateEventOutput(_Base):
+ success: bool
+ error: str | None = None
+ data: dict[str, Any] | None = None
diff --git a/src/modulex_integrations/tools/livestorm/tests/__init__.py b/src/modulex_integrations/tools/livestorm/tests/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/modulex_integrations/tools/livestorm/tests/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/modulex_integrations/tools/livestorm/tests/test_livestorm.py b/src/modulex_integrations/tools/livestorm/tests/test_livestorm.py
new file mode 100644
index 0000000..09f2a54
--- /dev/null
+++ b/src/modulex_integrations/tools/livestorm/tests/test_livestorm.py
@@ -0,0 +1,254 @@
+"""Happy-path tests for every livestorm @tool, plus a manifest sanity check."""
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from modulex_integrations.tools.livestorm import (
+ TOOLS,
+ create_event,
+ get_event,
+ list_attendees_from_event,
+ list_events,
+ list_sessions,
+ manifest,
+ register_someone_for_session,
+ update_event,
+)
+from modulex_integrations.tools.livestorm.outputs import (
+ CreateEventOutput,
+ GetEventOutput,
+ ListAttendeesFromEventOutput,
+ ListEventsOutput,
+ ListSessionsOutput,
+ RegisterSomeoneForSessionOutput,
+ UpdateEventOutput,
+)
+
+API = "https://api.livestorm.co/v1"
+
+_AUTH: dict[str, Any] = {
+ "auth_type": "oauth2",
+ "auth_data": {"access_token": "fake_access_token"},
+}
+
+
+def _args(**extra: Any) -> dict[str, Any]:
+ """Build a ``.ainvoke()`` input dict: auth + per-test extras."""
+ return dict(_AUTH, **extra)
+
+
+# --- Manifest sanity ----------------------------------------------------------
+
+
+class TestManifest:
+ def test_manifest_exposes_7_actions(self) -> None:
+ assert len(manifest.actions) == 7
+
+ def test_manifest_actions_match_tools_tuple(self) -> None:
+ assert {a.name for a in manifest.actions} == {t.name for t in TOOLS}
+
+ def test_manifest_has_oauth2_auth(self) -> None:
+ assert {a.auth_type for a in manifest.auth_schemas} == {"oauth2"}
+
+
+# --- Per-action happy-path tests ----------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_create_event(httpx_mock) -> None: # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/events",
+ status_code=201,
+ json={
+ "data": {
+ "id": "evt_123",
+ "type": "events",
+ "attributes": {"title": "My Webinar"},
+ }
+ },
+ )
+
+ result_dict = await create_event.ainvoke(
+ _args(owner_id="user_1", title="My Webinar")
+ )
+
+ assert isinstance(result_dict, dict)
+ result = CreateEventOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.data is not None
+ assert result.data["id"] == "evt_123"
+
+
+@pytest.mark.asyncio
+async def test_get_event(httpx_mock) -> None: # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/events/evt_123",
+ json={
+ "data": {
+ "id": "evt_123",
+ "type": "events",
+ "attributes": {"title": "My Webinar"},
+ }
+ },
+ )
+
+ result_dict = await get_event.ainvoke(_args(event_id="evt_123"))
+
+ assert isinstance(result_dict, dict)
+ result = GetEventOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.data is not None
+ assert result.data["id"] == "evt_123"
+
+
+@pytest.mark.asyncio
+async def test_list_attendees_from_event(httpx_mock) -> None: # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/events/evt_123/people?page%5Bnumber%5D=1",
+ json={
+ "data": [
+ {"id": "person_1", "type": "people", "attributes": {"email": "a@b.com"}}
+ ],
+ "meta": {"page_count": 1},
+ },
+ )
+
+ result_dict = await list_attendees_from_event.ainvoke(
+ _args(event_id="evt_123")
+ )
+
+ assert isinstance(result_dict, dict)
+ result = ListAttendeesFromEventOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.data) == 1
+
+
+@pytest.mark.asyncio
+async def test_list_events(httpx_mock) -> None: # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/events?page%5Bnumber%5D=1",
+ json={
+ "data": [
+ {"id": "evt_1", "type": "events", "attributes": {"title": "Event 1"}}
+ ],
+ "meta": {"page_count": 1},
+ },
+ )
+
+ result_dict = await list_events.ainvoke(_args())
+
+ assert isinstance(result_dict, dict)
+ result = ListEventsOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.data) == 1
+
+
+@pytest.mark.asyncio
+async def test_list_sessions(httpx_mock) -> None: # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/sessions?page%5Bnumber%5D=1",
+ json={
+ "data": [
+ {"id": "ses_1", "type": "sessions", "attributes": {"status": "upcoming"}}
+ ],
+ "meta": {"page_count": 1},
+ },
+ )
+
+ result_dict = await list_sessions.ainvoke(_args())
+
+ assert isinstance(result_dict, dict)
+ result = ListSessionsOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.data) == 1
+
+
+@pytest.mark.asyncio
+async def test_register_someone_for_session(httpx_mock) -> None: # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/sessions/ses_1/people",
+ status_code=201,
+ json={
+ "data": {
+ "id": "person_new",
+ "type": "people",
+ "attributes": {"email": "new@example.com"},
+ }
+ },
+ )
+
+ result_dict = await register_someone_for_session.ainvoke(
+ _args(session_id="ses_1")
+ )
+
+ assert isinstance(result_dict, dict)
+ result = RegisterSomeoneForSessionOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.data is not None
+ assert result.data["id"] == "person_new"
+
+
+@pytest.mark.asyncio
+async def test_update_event(httpx_mock) -> None: # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="PUT",
+ url=f"{API}/events/evt_123",
+ json={
+ "data": {
+ "id": "evt_123",
+ "type": "events",
+ "attributes": {"title": "Updated Title"},
+ }
+ },
+ )
+
+ result_dict = await update_event.ainvoke(
+ _args(
+ event_id="evt_123",
+ owner_id="user_1",
+ title="Updated Title",
+ slug="updated-title",
+ status="published",
+ description="Updated
",
+ recording_enabled=True,
+ chat_enabled=True,
+ everyone_can_speak=False,
+ detailed_registration_page_enabled=True,
+ light_registration_page_enabled=False,
+ recording_public=False,
+ show_in_company_page=True,
+ polls_enabled=True,
+ questions_enabled=True,
+ )
+ )
+
+ assert isinstance(result_dict, dict)
+ result = UpdateEventOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.data is not None
+ assert result.data["id"] == "evt_123"
+
+
+# --- Failure-path test --------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_create_event_missing_credentials() -> None:
+ """Empty credentials should return an error without hitting the API."""
+ result_dict = await create_event.ainvoke(
+ _args(owner_id="user_1", title="Test", auth_data={})
+ )
+
+ assert isinstance(result_dict, dict)
+ result = CreateEventOutput.model_validate(result_dict)
+ assert result.success is False
+ assert result.error is not None
+ assert "access_token" in result.error
diff --git a/src/modulex_integrations/tools/livestorm/tools.py b/src/modulex_integrations/tools/livestorm/tools.py
new file mode 100644
index 0000000..054d400
--- /dev/null
+++ b/src/modulex_integrations/tools/livestorm/tools.py
@@ -0,0 +1,507 @@
+"""Livestorm LangChain @tool functions."""
+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.livestorm.outputs import (
+ CreateEventOutput,
+ GetEventOutput,
+ ListAttendeesFromEventOutput,
+ ListEventsOutput,
+ ListSessionsOutput,
+ RegisterSomeoneForSessionOutput,
+ UpdateEventOutput,
+)
+
+__all__ = [
+ "create_event",
+ "get_event",
+ "list_attendees_from_event",
+ "list_events",
+ "list_sessions",
+ "register_someone_for_session",
+ "update_event",
+]
+
+_BASE_URL = "https://api.livestorm.co/v1"
+_TIMEOUT = 30.0
+
+
+def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]:
+ """Build headers for the Livestorm API based on auth_type/auth_data."""
+ headers: dict[str, str] = {
+ "Accept": "application/vnd.api+json",
+ "Content-Type": "application/vnd.api+json",
+ }
+ if auth_type == "oauth2":
+ access_token = auth_data.get("access_token")
+ if access_token:
+ headers["Authorization"] = f"Bearer {access_token}"
+ return headers
+
+
+# --- Input schemas ------------------------------------------------------------
+
+
+class CreateEventInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ owner_id: str = Field(description="The ID of the user who owns the event")
+ title: str = Field(description="The title of the event")
+ slug: str | None = Field(default=None, description="The slug of the event")
+ status: str | None = Field(default=None, description="The status of the event: draft, published")
+ description: str | None = Field(default=None, description="The HTML description of the event")
+ recording_enabled: bool | None = Field(default=None, description="Whether the event is recorded")
+ chat_enabled: bool | None = Field(default=None, description="Whether the chat is enabled")
+ everyone_can_speak: bool | None = Field(default=None, description="Whether everyone can speak")
+ detailed_registration_page_enabled: bool | None = Field(default=None, description="Whether the detailed registration page is enabled")
+ light_registration_page_enabled: bool | None = Field(default=None, description="Whether the light registration page is enabled")
+ recording_public: bool | None = Field(default=None, description="Whether the recording is public")
+ show_in_company_page: bool | None = Field(default=None, description="Whether the event is shown in the company page")
+ polls_enabled: bool | None = Field(default=None, description="Whether the polls are enabled")
+ questions_enabled: bool | None = Field(default=None, description="Whether the questions are enabled")
+
+
+class GetEventInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ event_id: str = Field(description="The ID of the event")
+
+
+class ListAttendeesFromEventInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ event_id: str = Field(description="The ID of the event")
+ role_filter: str | None = Field(default=None, description="Filter by role: participant, team_member")
+
+
+class ListEventsInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ title_filter: str | None = Field(default=None, description="Filter events by title")
+
+
+class ListSessionsInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+
+
+class RegisterSomeoneForSessionInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ session_id: str = Field(description="The ID of the session")
+ referrer: str | None = Field(default=None, description="The referrer of the person registering")
+ utm_source: str | None = Field(default=None, description="The UTM source")
+ utm_medium: str | None = Field(default=None, description="The UTM medium")
+ utm_campaign: str | None = Field(default=None, description="The UTM campaign")
+ utm_term: str | None = Field(default=None, description="The UTM term")
+ utm_content: str | None = Field(default=None, description="The UTM content")
+ fields: dict[str, Any] | None = Field(default=None, description="Registration fields as key-value pairs where key is the field ID and value is the field value")
+
+
+class UpdateEventInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ event_id: str = Field(description="The ID of the event")
+ owner_id: str = Field(description="The ID of the user who owns the event")
+ title: str = Field(description="The title of the event")
+ slug: str = Field(description="The slug of the event")
+ status: str = Field(description="The status of the event: draft, published")
+ description: str = Field(description="The HTML description of the event")
+ recording_enabled: bool = Field(description="Whether the event is recorded")
+ chat_enabled: bool = Field(description="Whether the chat is enabled")
+ everyone_can_speak: bool = Field(description="Whether everyone can speak")
+ detailed_registration_page_enabled: bool = Field(description="Whether the detailed registration page is enabled")
+ light_registration_page_enabled: bool = Field(description="Whether the light registration page is enabled")
+ recording_public: bool = Field(description="Whether the recording is public")
+ show_in_company_page: bool = Field(description="Whether the event is shown in the company page")
+ polls_enabled: bool = Field(description="Whether the polls are enabled")
+ questions_enabled: bool = Field(description="Whether the questions are enabled")
+
+
+# --- @tool functions ----------------------------------------------------------
+
+
+@tool(args_schema=CreateEventInput)
+@serialize_pydantic_return
+async def create_event(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ owner_id: str,
+ title: str,
+ slug: str | None = None,
+ status: str | None = None,
+ description: str | None = None,
+ recording_enabled: bool | None = None,
+ chat_enabled: bool | None = None,
+ everyone_can_speak: bool | None = None,
+ detailed_registration_page_enabled: bool | None = None,
+ light_registration_page_enabled: bool | None = None,
+ recording_public: bool | None = None,
+ show_in_company_page: bool | None = None,
+ polls_enabled: bool | None = None,
+ questions_enabled: bool | None = None,
+) -> CreateEventOutput:
+ """Create a new event."""
+ if not auth_data.get("access_token"):
+ return CreateEventOutput(success=False, error="Missing access_token in auth_data.")
+ headers = _get_auth_headers(auth_type, auth_data)
+ attributes: dict[str, Any] = {
+ "owner_id": owner_id,
+ "title": title,
+ }
+ if slug is not None:
+ attributes["slug"] = slug
+ if status is not None:
+ attributes["status"] = status
+ if description is not None:
+ attributes["description"] = description
+ if recording_enabled is not None:
+ attributes["recording_enabled"] = recording_enabled
+ if chat_enabled is not None:
+ attributes["chat_enabled"] = chat_enabled
+ if everyone_can_speak is not None:
+ attributes["everyone_can_speak"] = everyone_can_speak
+ if detailed_registration_page_enabled is not None:
+ attributes["detailed_registration_page_enabled"] = detailed_registration_page_enabled
+ if light_registration_page_enabled is not None:
+ attributes["light_registration_page_enabled"] = light_registration_page_enabled
+ if recording_public is not None:
+ attributes["recording_public"] = recording_public
+ if show_in_company_page is not None:
+ attributes["show_in_company_page"] = show_in_company_page
+ if polls_enabled is not None:
+ attributes["polls_enabled"] = polls_enabled
+ if questions_enabled is not None:
+ attributes["questions_enabled"] = questions_enabled
+
+ payload = {"data": {"type": "events", "attributes": attributes}}
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.post(
+ f"{_BASE_URL}/events",
+ headers=headers,
+ json=payload,
+ )
+ if response.status_code not in (200, 201):
+ return CreateEventOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return CreateEventOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return CreateEventOutput(success=False, error=f"Call failed: {exc}")
+
+ return CreateEventOutput(success=True, data=data.get("data"))
+
+
+@tool(args_schema=GetEventInput)
+@serialize_pydantic_return
+async def get_event(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ event_id: str,
+) -> GetEventOutput:
+ """Retrieve a single event."""
+ if not auth_data.get("access_token"):
+ return GetEventOutput(success=False, error="Missing access_token in auth_data.")
+ headers = _get_auth_headers(auth_type, auth_data)
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{_BASE_URL}/events/{event_id}",
+ headers=headers,
+ )
+ if response.status_code != 200:
+ return GetEventOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return GetEventOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return GetEventOutput(success=False, error=f"Call failed: {exc}")
+
+ return GetEventOutput(success=True, data=data.get("data"))
+
+
+@tool(args_schema=ListAttendeesFromEventInput)
+@serialize_pydantic_return
+async def list_attendees_from_event(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ event_id: str,
+ role_filter: str | None = None,
+) -> ListAttendeesFromEventOutput:
+ """List all the people linked to all the sessions of an event."""
+ if not auth_data.get("access_token"):
+ return ListAttendeesFromEventOutput(success=False, error="Missing access_token in auth_data.")
+ headers = _get_auth_headers(auth_type, auth_data)
+ params: dict[str, str] = {}
+ if role_filter is not None:
+ params["filter[role]"] = role_filter
+
+ all_items: list[dict[str, Any]] = []
+ page_number = 1
+ max_pages = 50
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ while page_number <= max_pages:
+ params["page[number]"] = str(page_number)
+ response = await client.get(
+ f"{_BASE_URL}/events/{event_id}/people",
+ headers=headers,
+ params=params,
+ )
+ if response.status_code != 200:
+ return ListAttendeesFromEventOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ body = response.json()
+ items = body.get("data", [])
+ if not items:
+ break
+ all_items.extend(items)
+ meta = body.get("meta", {})
+ if page_number >= meta.get("page_count", 1):
+ break
+ page_number += 1
+ except httpx.TimeoutException:
+ return ListAttendeesFromEventOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return ListAttendeesFromEventOutput(success=False, error=f"Call failed: {exc}")
+
+ return ListAttendeesFromEventOutput(success=True, data=all_items)
+
+
+@tool(args_schema=ListEventsInput)
+@serialize_pydantic_return
+async def list_events(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ title_filter: str | None = None,
+) -> ListEventsOutput:
+ """List the events of your workspace."""
+ if not auth_data.get("access_token"):
+ return ListEventsOutput(success=False, error="Missing access_token in auth_data.")
+ headers = _get_auth_headers(auth_type, auth_data)
+ params: dict[str, str] = {}
+ if title_filter is not None:
+ params["filter[title]"] = title_filter
+
+ all_items: list[dict[str, Any]] = []
+ page_number = 1
+ max_pages = 50
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ while page_number <= max_pages:
+ params["page[number]"] = str(page_number)
+ response = await client.get(
+ f"{_BASE_URL}/events",
+ headers=headers,
+ params=params,
+ )
+ if response.status_code != 200:
+ return ListEventsOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ body = response.json()
+ items = body.get("data", [])
+ if not items:
+ break
+ all_items.extend(items)
+ meta = body.get("meta", {})
+ if page_number >= meta.get("page_count", 1):
+ break
+ page_number += 1
+ except httpx.TimeoutException:
+ return ListEventsOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return ListEventsOutput(success=False, error=f"Call failed: {exc}")
+
+ return ListEventsOutput(success=True, data=all_items)
+
+
+@tool(args_schema=ListSessionsInput)
+@serialize_pydantic_return
+async def list_sessions(
+ auth_type: str,
+ auth_data: dict[str, Any],
+) -> ListSessionsOutput:
+ """List all your event sessions."""
+ if not auth_data.get("access_token"):
+ return ListSessionsOutput(success=False, error="Missing access_token in auth_data.")
+ headers = _get_auth_headers(auth_type, auth_data)
+
+ all_items: list[dict[str, Any]] = []
+ page_number = 1
+ max_pages = 50
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ while page_number <= max_pages:
+ response = await client.get(
+ f"{_BASE_URL}/sessions",
+ headers=headers,
+ params={"page[number]": str(page_number)},
+ )
+ if response.status_code != 200:
+ return ListSessionsOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ body = response.json()
+ items = body.get("data", [])
+ if not items:
+ break
+ all_items.extend(items)
+ meta = body.get("meta", {})
+ if page_number >= meta.get("page_count", 1):
+ break
+ page_number += 1
+ except httpx.TimeoutException:
+ return ListSessionsOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return ListSessionsOutput(success=False, error=f"Call failed: {exc}")
+
+ return ListSessionsOutput(success=True, data=all_items)
+
+
+@tool(args_schema=RegisterSomeoneForSessionInput)
+@serialize_pydantic_return
+async def register_someone_for_session(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ session_id: str,
+ referrer: str | None = None,
+ utm_source: str | None = None,
+ utm_medium: str | None = None,
+ utm_campaign: str | None = None,
+ utm_term: str | None = None,
+ utm_content: str | None = None,
+ fields: dict[str, Any] | None = None,
+) -> RegisterSomeoneForSessionOutput:
+ """Register a new participant for a session."""
+ if not auth_data.get("access_token"):
+ return RegisterSomeoneForSessionOutput(success=False, error="Missing access_token in auth_data.")
+ headers = _get_auth_headers(auth_type, auth_data)
+
+ attributes: dict[str, Any] = {}
+ if referrer is not None:
+ attributes["referrer"] = referrer
+ if utm_source is not None:
+ attributes["utm_source"] = utm_source
+ if utm_medium is not None:
+ attributes["utm_medium"] = utm_medium
+ if utm_campaign is not None:
+ attributes["utm_campaign"] = utm_campaign
+ if utm_term is not None:
+ attributes["utm_term"] = utm_term
+ if utm_content is not None:
+ attributes["utm_content"] = utm_content
+
+ fields_array: list[dict[str, Any]] = []
+ if fields:
+ for field_id, value in fields.items():
+ fields_array.append({"id": field_id, "value": value})
+ if fields_array:
+ attributes["fields"] = fields_array
+
+ payload: dict[str, Any] = {
+ "data": {"type": "people", "attributes": attributes},
+ }
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.post(
+ f"{_BASE_URL}/sessions/{session_id}/people",
+ headers=headers,
+ json=payload,
+ )
+ if response.status_code not in (200, 201):
+ return RegisterSomeoneForSessionOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return RegisterSomeoneForSessionOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return RegisterSomeoneForSessionOutput(success=False, error=f"Call failed: {exc}")
+
+ return RegisterSomeoneForSessionOutput(success=True, data=data.get("data"))
+
+
+@tool(args_schema=UpdateEventInput)
+@serialize_pydantic_return
+async def update_event(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ event_id: str,
+ owner_id: str,
+ title: str,
+ slug: str,
+ status: str,
+ description: str,
+ recording_enabled: bool,
+ chat_enabled: bool,
+ everyone_can_speak: bool,
+ detailed_registration_page_enabled: bool,
+ light_registration_page_enabled: bool,
+ recording_public: bool,
+ show_in_company_page: bool,
+ polls_enabled: bool,
+ questions_enabled: bool,
+) -> UpdateEventOutput:
+ """Update an event with its full list of attributes."""
+ if not auth_data.get("access_token"):
+ return UpdateEventOutput(success=False, error="Missing access_token in auth_data.")
+ headers = _get_auth_headers(auth_type, auth_data)
+ attributes: dict[str, Any] = {
+ "owner_id": owner_id,
+ "title": title,
+ "slug": slug,
+ "status": status,
+ "description": description,
+ "recording_enabled": recording_enabled,
+ "chat_enabled": chat_enabled,
+ "everyone_can_speak": everyone_can_speak,
+ "detailed_registration_page_enabled": detailed_registration_page_enabled,
+ "light_registration_page_enabled": light_registration_page_enabled,
+ "recording_public": recording_public,
+ "show_in_company_page": show_in_company_page,
+ "polls_enabled": polls_enabled,
+ "questions_enabled": questions_enabled,
+ }
+
+ payload = {"data": {"type": "events", "attributes": attributes}}
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.put(
+ f"{_BASE_URL}/events/{event_id}",
+ headers=headers,
+ json=payload,
+ )
+ if response.status_code != 200:
+ return UpdateEventOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return UpdateEventOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return UpdateEventOutput(success=False, error=f"Call failed: {exc}")
+
+ return UpdateEventOutput(success=True, data=data.get("data"))
diff --git a/src/modulex_integrations/tools/motion/README.md b/src/modulex_integrations/tools/motion/README.md
new file mode 100644
index 0000000..07c9cc4
--- /dev/null
+++ b/src/modulex_integrations/tools/motion/README.md
@@ -0,0 +1,33 @@
+# Motion
+
+AI-powered task and project management platform with automatic scheduling, accessed via the Motion REST API (`api.usemotion.com/v1`).
+
+## Authentication
+
+### API Key Authentication
+
+- Sign in at , navigate to **Settings > API**, and generate or copy your API key.
+- Required env var: `MOTION_API_KEY` (format: `xxxxxxxxxxxxxxxxxxxxx`).
+
+## Tools
+
+| name | description | required params |
+| --- | --- | --- |
+| `create_task` | Create a new task in a Motion workspace | `workspace_id`, `name` |
+| `delete_task` | Delete a specific task by ID | `task_id` |
+| `get_schedules` | Get a list of schedules for the authenticated user | — |
+| `get_task` | Retrieve a specific task by ID | `task_id` |
+| `move_workspace` | Move a task to another workspace. Resets the task's project, status, labels, and assignee | `task_id`, `workspace_id` |
+| `update_task` | Update a specific task's properties | `task_id` |
+
+Every tool takes an additional `api_key` parameter that the runtime fills in from the resolved credential.
+
+## Limits & Quotas
+
+- **Rate limit**: 12 requests per minute per API key (Motion API documentation).
+- **Pricing**: API access requires a Motion Individual or Team plan.
+- **Error model**: non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising.
+
+## Maintainer
+
+ModuleX core team.
diff --git a/src/modulex_integrations/tools/motion/__init__.py b/src/modulex_integrations/tools/motion/__init__.py
new file mode 100644
index 0000000..04c703f
--- /dev/null
+++ b/src/modulex_integrations/tools/motion/__init__.py
@@ -0,0 +1,30 @@
+"""Motion integration — discovered via the ``modulex.tools`` entry point."""
+from modulex_integrations.tools.motion.manifest import manifest
+from modulex_integrations.tools.motion.tools import (
+ create_task,
+ delete_task,
+ get_schedules,
+ get_task,
+ move_workspace,
+ update_task,
+)
+
+TOOLS = (
+ create_task,
+ delete_task,
+ get_schedules,
+ get_task,
+ move_workspace,
+ update_task,
+)
+
+__all__ = [
+ "TOOLS",
+ "create_task",
+ "delete_task",
+ "get_schedules",
+ "get_task",
+ "manifest",
+ "move_workspace",
+ "update_task",
+]
diff --git a/src/modulex_integrations/tools/motion/dependencies.toml b/src/modulex_integrations/tools/motion/dependencies.toml
new file mode 100644
index 0000000..c857a56
--- /dev/null
+++ b/src/modulex_integrations/tools/motion/dependencies.toml
@@ -0,0 +1,3 @@
+# Runtime dependencies for the motion integration.
+# CI assembles this into the root pyproject's [project.optional-dependencies].
+dependencies = []
diff --git a/src/modulex_integrations/tools/motion/manifest.py b/src/modulex_integrations/tools/motion/manifest.py
new file mode 100644
index 0000000..1503522
--- /dev/null
+++ b/src/modulex_integrations/tools/motion/manifest.py
@@ -0,0 +1,232 @@
+"""Motion integration manifest."""
+from __future__ import annotations
+
+from modulex_integrations.schema import (
+ ActionDefinition,
+ ApiKeyAuthSchema,
+ EnvVar,
+ IntegrationManifest,
+ ParameterDef,
+ SuccessIndicators,
+ TestEndpoint,
+)
+
+__all__ = ["manifest"]
+
+
+manifest = IntegrationManifest(
+ name="motion",
+ display_name="Motion",
+ description="AI-powered task and project management platform for automatic scheduling",
+ version="1.0.0",
+ author="ModuleX",
+ logo="modulex:motion-themed",
+ app_url="https://www.usemotion.com",
+ categories=["Productivity & Collaboration", "project-management"],
+ actions=[
+ ActionDefinition(
+ name="create_task",
+ description="Create a new task in a Motion workspace",
+ parameters={
+ "workspace_id": ParameterDef(
+ type="string",
+ description="The ID of the workspace",
+ required=True,
+ ),
+ "name": ParameterDef(
+ type="string",
+ description="Name / title of the task",
+ required=True,
+ ),
+ "project_id": ParameterDef(
+ type="string",
+ description="The ID of the project to assign the task to",
+ ),
+ "due_date": ParameterDef(
+ type="string",
+ description="ISO 8601 due date. Required for scheduled tasks. Example: 2023-06-28T10:11:14.320-06:00",
+ ),
+ "duration": ParameterDef(
+ type="string",
+ description="Duration: NONE, REMINDER, or an integer greater than 0",
+ ),
+ "description": ParameterDef(
+ type="string",
+ description="Task description in GitHub Flavored Markdown",
+ ),
+ "priority": ParameterDef(
+ type="string",
+ description="Priority level: ASAP, HIGH, MEDIUM, LOW",
+ default="MEDIUM",
+ ),
+ "assignee_id": ParameterDef(
+ type="string",
+ description="The user ID to assign the task to",
+ ),
+ "labels": ParameterDef(
+ type="array",
+ description="List of label names to add to the task",
+ ),
+ "status": ParameterDef(
+ type="string",
+ description="The name of the task status",
+ ),
+ "start_date": ParameterDef(
+ type="string",
+ description="ISO 8601 date for auto-scheduled tasks. Example: 2023-06-28",
+ ),
+ "deadline_type": ParameterDef(
+ type="string",
+ description="Deadline type for auto-scheduled tasks: HARD, SOFT, NONE",
+ ),
+ "schedule": ParameterDef(
+ type="string",
+ description="Schedule the task must adhere to. Must be 'Work Hours' if scheduling for another user",
+ default="Work Hours",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="delete_task",
+ description="Delete a specific task by ID",
+ parameters={
+ "task_id": ParameterDef(
+ type="string",
+ description="The ID of the task to delete",
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="get_schedules",
+ description="Get a list of schedules for the authenticated user",
+ parameters={},
+ ),
+ ActionDefinition(
+ name="get_task",
+ description="Retrieve a specific task by ID",
+ parameters={
+ "task_id": ParameterDef(
+ type="string",
+ description="The ID of the task to retrieve",
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="move_workspace",
+ description="Move a task to another workspace. Resets the task's project, status, labels, and assignee",
+ parameters={
+ "task_id": ParameterDef(
+ type="string",
+ description="The ID of the task to move",
+ required=True,
+ ),
+ "workspace_id": ParameterDef(
+ type="string",
+ description="The ID of the target workspace",
+ required=True,
+ ),
+ "assignee_id": ParameterDef(
+ type="string",
+ description="The user ID to assign the task to in the target workspace",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="update_task",
+ description="Update a specific task's properties",
+ parameters={
+ "task_id": ParameterDef(
+ type="string",
+ description="The ID of the task to update",
+ required=True,
+ ),
+ "name": ParameterDef(
+ type="string",
+ description="New name / title for the task",
+ ),
+ "due_date": ParameterDef(
+ type="string",
+ description="ISO 8601 due date. Example: 2023-06-28T10:11:14.320-06:00",
+ ),
+ "duration": ParameterDef(
+ type="string",
+ description="Duration: NONE, REMINDER, or an integer greater than 0",
+ ),
+ "project_id": ParameterDef(
+ type="string",
+ description="The ID of the project to assign the task to",
+ ),
+ "description": ParameterDef(
+ type="string",
+ description="Task description in GitHub Flavored Markdown",
+ ),
+ "priority": ParameterDef(
+ type="string",
+ description="Priority level: ASAP, HIGH, MEDIUM, LOW",
+ default="MEDIUM",
+ ),
+ "assignee_id": ParameterDef(
+ type="string",
+ description="The user ID to assign the task to",
+ ),
+ "labels": ParameterDef(
+ type="array",
+ description="List of label names to add to the task",
+ ),
+ "status": ParameterDef(
+ type="string",
+ description="The name of the task status",
+ ),
+ "start_date": ParameterDef(
+ type="string",
+ description="ISO 8601 date for auto-scheduled tasks. Example: 2023-06-28",
+ ),
+ "deadline_type": ParameterDef(
+ type="string",
+ description="Deadline type for auto-scheduled tasks: HARD, SOFT, NONE",
+ ),
+ "schedule": ParameterDef(
+ type="string",
+ description="Schedule the task must adhere to. Must be 'Work Hours' if scheduling for another user",
+ default="Work Hours",
+ ),
+ },
+ ),
+ ],
+ auth_schemas=[
+ ApiKeyAuthSchema(
+ display_name="API Key Authentication",
+ description="Authenticate using your Motion API key",
+ setup_instructions=[
+ "Go to https://app.usemotion.com and sign in",
+ "Navigate to Settings > API",
+ "Generate a new API key or copy your existing one",
+ "Paste the API key below",
+ ],
+ setup_environment_variables=[
+ EnvVar(
+ name="MOTION_API_KEY",
+ display_name="Motion API Key",
+ description="Your Motion API key from the Settings > API page",
+ required=True,
+ sensitive=True,
+ sample_format="xxxxxxxxxxxxxxxxxxxxx",
+ about_url="https://app.usemotion.com/settings",
+ ),
+ ],
+ test_endpoint=TestEndpoint(
+ url="https://api.usemotion.com/v1/users/me",
+ method="GET",
+ headers={"X-API-Key": "{api_key}"},
+ success_indicators=SuccessIndicators(
+ status_codes=[200],
+ response_fields=["id"],
+ ),
+ cost_level="free",
+ description="Validates the API key by fetching the current user",
+ ),
+ ),
+ ],
+)
diff --git a/src/modulex_integrations/tools/motion/outputs.py b/src/modulex_integrations/tools/motion/outputs.py
new file mode 100644
index 0000000..3517921
--- /dev/null
+++ b/src/modulex_integrations/tools/motion/outputs.py
@@ -0,0 +1,97 @@
+"""Pydantic response models for the motion integration's @tool functions."""
+from __future__ import annotations
+
+from pydantic import BaseModel, ConfigDict, Field
+
+__all__ = [
+ "CreateTaskOutput",
+ "DeleteTaskOutput",
+ "GetSchedulesOutput",
+ "GetTaskOutput",
+ "MoveWorkspaceOutput",
+ "ScheduleItem",
+ "TaskObject",
+ "TaskStatus",
+ "UpdateTaskOutput",
+]
+
+
+class _Base(BaseModel):
+ """Shared config for every output model in this integration."""
+
+ model_config = ConfigDict(extra="forbid")
+
+
+# --- Nested resource models -----------------------------------------------
+
+
+class TaskStatus(_Base):
+ """Status information for a task."""
+
+ name: str | None = None
+ is_default_status: bool | None = None
+ is_resolved_status: bool | None = None
+
+
+class TaskObject(_Base):
+ """A Motion task object returned by task-related actions."""
+
+ id: str | None = None
+ name: str | None = None
+ description: str | None = None
+ status: TaskStatus | None = None
+ workspace_id: str | None = None
+ project_id: str | None = None
+ priority: str | None = None
+ assignee_id: str | None = None
+ labels: list[str] = Field(default_factory=list)
+ due_date: str | None = None
+ duration: str | None = None
+ created_at: str | None = None
+
+
+class ScheduleItem(_Base):
+ """A schedule object returned by the get_schedules action."""
+
+ id: str | None = None
+ name: str | None = None
+ timezone: str | None = None
+ is_default: bool | None = None
+
+
+# --- Per-action output models ----------------------------------------------
+
+
+class CreateTaskOutput(_Base):
+ success: bool
+ error: str | None = None
+ task: TaskObject | None = None
+
+
+class DeleteTaskOutput(_Base):
+ success: bool
+ error: str | None = None
+
+
+class GetSchedulesOutput(_Base):
+ success: bool
+ error: str | None = None
+ schedules: list[ScheduleItem] = Field(default_factory=list)
+
+
+class GetTaskOutput(_Base):
+ success: bool
+ error: str | None = None
+ task: TaskObject | None = None
+
+
+class MoveWorkspaceOutput(_Base):
+ success: bool
+ error: str | None = None
+ task: TaskObject | None = None
+
+
+class UpdateTaskOutput(_Base):
+ success: bool
+ error: str | None = None
+ task: TaskObject | None = None
diff --git a/src/modulex_integrations/tools/motion/tests/__init__.py b/src/modulex_integrations/tools/motion/tests/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/modulex_integrations/tools/motion/tests/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/modulex_integrations/tools/motion/tests/test_motion.py b/src/modulex_integrations/tools/motion/tests/test_motion.py
new file mode 100644
index 0000000..a541b1e
--- /dev/null
+++ b/src/modulex_integrations/tools/motion/tests/test_motion.py
@@ -0,0 +1,231 @@
+"""Happy-path tests for every motion @tool, plus a manifest sanity check."""
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from modulex_integrations.tools.motion import (
+ TOOLS,
+ create_task,
+ delete_task,
+ get_schedules,
+ get_task,
+ manifest,
+ move_workspace,
+ update_task,
+)
+from modulex_integrations.tools.motion.outputs import (
+ CreateTaskOutput,
+ DeleteTaskOutput,
+ GetSchedulesOutput,
+ GetTaskOutput,
+ MoveWorkspaceOutput,
+ UpdateTaskOutput,
+)
+
+API = "https://api.usemotion.com/v1"
+
+_API_KEY = "fake-api-key"
+
+
+def _args(**extra: Any) -> dict[str, Any]:
+ return dict(api_key=_API_KEY, **extra)
+
+
+# --- Manifest sanity --------------------------------------------------------
+
+
+class TestManifest:
+ def test_manifest_exposes_6_actions(self) -> None:
+ assert len(manifest.actions) == 6
+
+ def test_manifest_actions_match_tools_tuple(self) -> None:
+ assert {a.name for a in manifest.actions} == {t.name for t in TOOLS}
+
+ def test_manifest_has_api_key_auth(self) -> None:
+ assert {a.auth_type for a in manifest.auth_schemas} == {"api_key"}
+
+
+# --- Per-action happy-path tests -------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_create_task(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/tasks",
+ json={
+ # TODO: fill in a representative response shape from the Motion API docs
+ "id": "task_123",
+ "name": "Test Task",
+ "description": None,
+ "status": {"name": "To Do", "isDefaultStatus": True, "isResolvedStatus": False},
+ "workspaceId": "ws_1",
+ "projectId": None,
+ "priority": "MEDIUM",
+ "assigneeId": None,
+ "labels": [],
+ "dueDate": None,
+ "duration": None,
+ "createdAt": "2023-06-28T10:00:00Z",
+ },
+ )
+
+ result_dict = await create_task.ainvoke(
+ _args(workspace_id="ws_1", name="Test Task")
+ )
+
+ assert isinstance(result_dict, dict)
+ result = CreateTaskOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.task is not None
+ assert result.task.id == "task_123"
+
+ sent = httpx_mock.get_requests()[0]
+ assert sent.headers["X-API-Key"] == _API_KEY
+
+
+@pytest.mark.asyncio
+async def test_delete_task(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="DELETE",
+ url=f"{API}/tasks/task_456",
+ status_code=204,
+ )
+
+ result_dict = await delete_task.ainvoke(_args(task_id="task_456"))
+
+ assert isinstance(result_dict, dict)
+ result = DeleteTaskOutput.model_validate(result_dict)
+ assert result.success is True
+
+
+@pytest.mark.asyncio
+async def test_get_schedules(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/schedules",
+ json=[
+ # TODO: fill in a representative response shape from the Motion API docs
+ {"id": "sched_1", "name": "Work Hours", "timezone": "America/New_York", "isDefault": True},
+ ],
+ )
+
+ result_dict = await get_schedules.ainvoke(_args())
+
+ assert isinstance(result_dict, dict)
+ result = GetSchedulesOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.schedules) == 1
+ assert result.schedules[0].name == "Work Hours"
+
+
+@pytest.mark.asyncio
+async def test_get_task(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/tasks/task_789",
+ json={
+ # TODO: fill in a representative response shape from the Motion API docs
+ "id": "task_789",
+ "name": "My Task",
+ "description": "A description",
+ "status": {"name": "In Progress", "isDefaultStatus": False, "isResolvedStatus": False},
+ "workspaceId": "ws_1",
+ "projectId": "proj_1",
+ "priority": "HIGH",
+ "assigneeId": "user_1",
+ "labels": ["urgent"],
+ "dueDate": "2023-07-01T00:00:00Z",
+ "duration": "60",
+ "createdAt": "2023-06-20T08:00:00Z",
+ },
+ )
+
+ result_dict = await get_task.ainvoke(_args(task_id="task_789"))
+
+ assert isinstance(result_dict, dict)
+ result = GetTaskOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.task is not None
+ assert result.task.name == "My Task"
+
+
+@pytest.mark.asyncio
+async def test_move_workspace(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="PATCH",
+ url=f"{API}/tasks/task_100/move",
+ json={
+ # TODO: fill in a representative response shape from the Motion API docs
+ "id": "task_100",
+ "name": "Moved Task",
+ "description": None,
+ "status": {"name": "To Do", "isDefaultStatus": True, "isResolvedStatus": False},
+ "workspaceId": "ws_2",
+ "projectId": None,
+ "priority": "LOW",
+ "assigneeId": None,
+ "labels": [],
+ "dueDate": None,
+ "duration": None,
+ "createdAt": "2023-06-15T12:00:00Z",
+ },
+ )
+
+ result_dict = await move_workspace.ainvoke(
+ _args(task_id="task_100", workspace_id="ws_2")
+ )
+
+ assert isinstance(result_dict, dict)
+ result = MoveWorkspaceOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.task is not None
+ assert result.task.workspace_id == "ws_2"
+
+
+@pytest.mark.asyncio
+async def test_update_task(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="PATCH",
+ url=f"{API}/tasks/task_200",
+ json={
+ # TODO: fill in a representative response shape from the Motion API docs
+ "id": "task_200",
+ "name": "Updated Task",
+ "description": "New description",
+ "status": {"name": "In Progress", "isDefaultStatus": False, "isResolvedStatus": False},
+ "workspaceId": "ws_1",
+ "projectId": "proj_2",
+ "priority": "HIGH",
+ "assigneeId": "user_2",
+ "labels": ["review"],
+ "dueDate": "2023-08-01T00:00:00Z",
+ "duration": "30",
+ "createdAt": "2023-06-10T09:00:00Z",
+ },
+ )
+
+ result_dict = await update_task.ainvoke(
+ _args(task_id="task_200", name="Updated Task", priority="HIGH")
+ )
+
+ assert isinstance(result_dict, dict)
+ result = UpdateTaskOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.task is not None
+ assert result.task.name == "Updated Task"
+
+
+# --- Failure-path tests ----------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_create_task_validates_empty_api_key() -> None:
+ result_dict = await create_task.ainvoke(
+ {"workspace_id": "ws_1", "name": "Test", "api_key": ""}
+ )
+ result = CreateTaskOutput.model_validate(result_dict)
+ assert result.success is False
+ assert "API key" in (result.error or "")
diff --git a/src/modulex_integrations/tools/motion/tools.py b/src/modulex_integrations/tools/motion/tools.py
new file mode 100644
index 0000000..221de5d
--- /dev/null
+++ b/src/modulex_integrations/tools/motion/tools.py
@@ -0,0 +1,367 @@
+"""Motion LangChain @tool functions."""
+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.motion.outputs import (
+ CreateTaskOutput,
+ DeleteTaskOutput,
+ GetSchedulesOutput,
+ GetTaskOutput,
+ MoveWorkspaceOutput,
+ ScheduleItem,
+ TaskObject,
+ TaskStatus,
+ UpdateTaskOutput,
+)
+
+__all__ = [
+ "create_task",
+ "delete_task",
+ "get_schedules",
+ "get_task",
+ "move_workspace",
+ "update_task",
+]
+
+_BASE_URL = "https://api.usemotion.com/v1"
+_TIMEOUT = 30.0
+
+
+def _headers(api_key: str) -> dict[str, str]:
+ return {
+ "X-API-Key": api_key,
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ }
+
+
+def _parse_task(data: dict[str, Any]) -> TaskObject:
+ status_raw = data.get("status")
+ status = None
+ if isinstance(status_raw, dict):
+ status = TaskStatus(
+ name=status_raw.get("name"),
+ is_default_status=status_raw.get("isDefaultStatus"),
+ is_resolved_status=status_raw.get("isResolvedStatus"),
+ )
+ return TaskObject(
+ id=data.get("id"),
+ name=data.get("name"),
+ description=data.get("description"),
+ status=status,
+ workspace_id=data.get("workspaceId"),
+ project_id=data.get("projectId"),
+ priority=data.get("priority"),
+ assignee_id=data.get("assigneeId"),
+ labels=data.get("labels") or [],
+ due_date=data.get("dueDate"),
+ duration=str(data["duration"]) if data.get("duration") is not None else None,
+ created_at=data.get("createdAt"),
+ )
+
+
+# --- Input schemas --------------------------------------------------------
+
+
+class CreateTaskInput(BaseModel):
+ workspace_id: str = Field(description="The ID of the workspace")
+ name: str = Field(description="Name / title of the task")
+ api_key: str = Field(description="Motion API key")
+ project_id: str | None = Field(default=None, description="The ID of the project to assign the task to")
+ due_date: str | None = Field(default=None, description="ISO 8601 due date. Required for scheduled tasks")
+ duration: str | None = Field(default=None, description="Duration: NONE, REMINDER, or an integer greater than 0")
+ description: str | None = Field(default=None, description="Task description in GitHub Flavored Markdown")
+ priority: str = Field(default="MEDIUM", description="Priority level: ASAP, HIGH, MEDIUM, LOW")
+ assignee_id: str | None = Field(default=None, description="The user ID to assign the task to")
+ labels: list[str] | None = Field(default=None, description="List of label names to add to the task")
+ status: str | None = Field(default=None, description="The name of the task status")
+ start_date: str | None = Field(default=None, description="ISO 8601 date for auto-scheduled tasks")
+ deadline_type: str | None = Field(default=None, description="Deadline type for auto-scheduled tasks: HARD, SOFT, NONE")
+ schedule: str | None = Field(default=None, description="Schedule the task must adhere to")
+
+
+class DeleteTaskInput(BaseModel):
+ task_id: str = Field(description="The ID of the task to delete")
+ api_key: str = Field(description="Motion API key")
+
+
+class GetSchedulesInput(BaseModel):
+ api_key: str = Field(description="Motion API key")
+
+
+class GetTaskInput(BaseModel):
+ task_id: str = Field(description="The ID of the task to retrieve")
+ api_key: str = Field(description="Motion API key")
+
+
+class MoveWorkspaceInput(BaseModel):
+ task_id: str = Field(description="The ID of the task to move")
+ workspace_id: str = Field(description="The ID of the target workspace")
+ api_key: str = Field(description="Motion API key")
+ assignee_id: str | None = Field(default=None, description="The user ID to assign the task to in the target workspace")
+
+
+class UpdateTaskInput(BaseModel):
+ task_id: str = Field(description="The ID of the task to update")
+ api_key: str = Field(description="Motion API key")
+ name: str | None = Field(default=None, description="New name / title for the task")
+ due_date: str | None = Field(default=None, description="ISO 8601 due date")
+ duration: str | None = Field(default=None, description="Duration: NONE, REMINDER, or an integer greater than 0")
+ project_id: str | None = Field(default=None, description="The ID of the project to assign the task to")
+ description: str | None = Field(default=None, description="Task description in GitHub Flavored Markdown")
+ priority: str | None = Field(default=None, description="Priority level: ASAP, HIGH, MEDIUM, LOW")
+ assignee_id: str | None = Field(default=None, description="The user ID to assign the task to")
+ labels: list[str] | None = Field(default=None, description="List of label names to add to the task")
+ status: str | None = Field(default=None, description="The name of the task status")
+ start_date: str | None = Field(default=None, description="ISO 8601 date for auto-scheduled tasks")
+ deadline_type: str | None = Field(default=None, description="Deadline type for auto-scheduled tasks: HARD, SOFT, NONE")
+ schedule: str | None = Field(default=None, description="Schedule the task must adhere to")
+
+
+# --- @tool functions ------------------------------------------------------
+
+
+@tool(args_schema=CreateTaskInput)
+@serialize_pydantic_return
+async def create_task(
+ workspace_id: str,
+ name: str,
+ api_key: str,
+ project_id: str | None = None,
+ due_date: str | None = None,
+ duration: str | None = None,
+ description: str | None = None,
+ priority: str = "MEDIUM",
+ assignee_id: str | None = None,
+ labels: list[str] | None = None,
+ status: str | None = None,
+ start_date: str | None = None,
+ deadline_type: str | None = None,
+ schedule: str | None = None,
+) -> CreateTaskOutput:
+ """Create a new task in a Motion workspace."""
+ if not api_key or not api_key.strip():
+ return CreateTaskOutput(success=False, error="API key is empty. Please configure a valid credential.")
+ body: dict[str, Any] = {
+ "workspaceId": workspace_id,
+ "name": name,
+ "priority": priority,
+ }
+ if project_id is not None:
+ body["projectId"] = project_id
+ if due_date is not None:
+ body["dueDate"] = due_date
+ if duration is not None:
+ body["duration"] = duration
+ if description is not None:
+ body["description"] = description
+ if assignee_id is not None:
+ body["assigneeId"] = assignee_id
+ if labels is not None:
+ body["labels"] = labels
+ if status is not None:
+ body["status"] = status
+ if start_date is not None:
+ body["startDate"] = start_date
+ if deadline_type is not None:
+ body["deadlineType"] = deadline_type
+ if schedule is not None:
+ body["schedule"] = schedule
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.post(
+ f"{_BASE_URL}/tasks",
+ headers=_headers(api_key),
+ json=body,
+ )
+ if response.status_code not in (200, 201):
+ return CreateTaskOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return CreateTaskOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return CreateTaskOutput(success=False, error=f"Call failed: {exc}")
+ return CreateTaskOutput(success=True, task=_parse_task(data))
+
+
+@tool(args_schema=DeleteTaskInput)
+@serialize_pydantic_return
+async def delete_task(
+ task_id: str,
+ api_key: str,
+) -> DeleteTaskOutput:
+ """Delete a specific task by ID."""
+ if not api_key or not api_key.strip():
+ return DeleteTaskOutput(success=False, error="API key is empty. Please configure a valid credential.")
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.delete(
+ f"{_BASE_URL}/tasks/{task_id}",
+ headers=_headers(api_key),
+ )
+ if response.status_code not in (200, 204):
+ return DeleteTaskOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ except httpx.TimeoutException:
+ return DeleteTaskOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return DeleteTaskOutput(success=False, error=f"Call failed: {exc}")
+ return DeleteTaskOutput(success=True)
+
+
+@tool(args_schema=GetSchedulesInput)
+@serialize_pydantic_return
+async def get_schedules(
+ api_key: str,
+) -> GetSchedulesOutput:
+ """Get a list of schedules for the authenticated user."""
+ if not api_key or not api_key.strip():
+ return GetSchedulesOutput(success=False, error="API key is empty. Please configure a valid credential.")
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{_BASE_URL}/schedules",
+ headers=_headers(api_key),
+ )
+ if response.status_code != 200:
+ return GetSchedulesOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return GetSchedulesOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return GetSchedulesOutput(success=False, error=f"Call failed: {exc}")
+ items = data if isinstance(data, list) else data.get("schedules", [])
+ schedules = [
+ ScheduleItem(
+ id=s.get("id"),
+ name=s.get("name"),
+ timezone=s.get("timezone"),
+ is_default=s.get("isDefault"),
+ )
+ for s in items
+ ]
+ return GetSchedulesOutput(success=True, schedules=schedules)
+
+
+@tool(args_schema=GetTaskInput)
+@serialize_pydantic_return
+async def get_task(
+ task_id: str,
+ api_key: str,
+) -> GetTaskOutput:
+ """Retrieve a specific task by ID."""
+ if not api_key or not api_key.strip():
+ return GetTaskOutput(success=False, error="API key is empty. Please configure a valid credential.")
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{_BASE_URL}/tasks/{task_id}",
+ headers=_headers(api_key),
+ )
+ if response.status_code != 200:
+ return GetTaskOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return GetTaskOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return GetTaskOutput(success=False, error=f"Call failed: {exc}")
+ return GetTaskOutput(success=True, task=_parse_task(data))
+
+
+@tool(args_schema=MoveWorkspaceInput)
+@serialize_pydantic_return
+async def move_workspace(
+ task_id: str,
+ workspace_id: str,
+ api_key: str,
+ assignee_id: str | None = None,
+) -> MoveWorkspaceOutput:
+ """Move a task to another workspace. Resets the task's project, status, labels, and assignee."""
+ if not api_key or not api_key.strip():
+ return MoveWorkspaceOutput(success=False, error="API key is empty. Please configure a valid credential.")
+ body: dict[str, Any] = {"workspaceId": workspace_id}
+ if assignee_id is not None:
+ body["assigneeId"] = assignee_id
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.patch(
+ f"{_BASE_URL}/tasks/{task_id}/move",
+ headers=_headers(api_key),
+ json=body,
+ )
+ if response.status_code != 200:
+ return MoveWorkspaceOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return MoveWorkspaceOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return MoveWorkspaceOutput(success=False, error=f"Call failed: {exc}")
+ return MoveWorkspaceOutput(success=True, task=_parse_task(data))
+
+
+@tool(args_schema=UpdateTaskInput)
+@serialize_pydantic_return
+async def update_task(
+ task_id: str,
+ api_key: str,
+ name: str | None = None,
+ due_date: str | None = None,
+ duration: str | None = None,
+ project_id: str | None = None,
+ description: str | None = None,
+ priority: str | None = None,
+ assignee_id: str | None = None,
+ labels: list[str] | None = None,
+ status: str | None = None,
+ start_date: str | None = None,
+ deadline_type: str | None = None,
+ schedule: str | None = None,
+) -> UpdateTaskOutput:
+ """Update a specific task's properties."""
+ if not api_key or not api_key.strip():
+ return UpdateTaskOutput(success=False, error="API key is empty. Please configure a valid credential.")
+ body: dict[str, Any] = {}
+ if name is not None:
+ body["name"] = name
+ if due_date is not None:
+ body["dueDate"] = due_date
+ if duration is not None:
+ body["duration"] = duration
+ if project_id is not None:
+ body["projectId"] = project_id
+ if description is not None:
+ body["description"] = description
+ if priority is not None:
+ body["priority"] = priority
+ if assignee_id is not None:
+ body["assigneeId"] = assignee_id
+ if labels is not None:
+ body["labels"] = labels
+ if status is not None:
+ body["status"] = status
+ if start_date is not None:
+ body["startDate"] = start_date
+ if deadline_type is not None:
+ body["deadlineType"] = deadline_type
+ if schedule is not None:
+ body["schedule"] = schedule
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.patch(
+ f"{_BASE_URL}/tasks/{task_id}",
+ headers=_headers(api_key),
+ json=body,
+ )
+ if response.status_code != 200:
+ return UpdateTaskOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return UpdateTaskOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return UpdateTaskOutput(success=False, error=f"Call failed: {exc}")
+ return UpdateTaskOutput(success=True, task=_parse_task(data))
diff --git a/src/modulex_integrations/tools/revolt/README.md b/src/modulex_integrations/tools/revolt/README.md
new file mode 100644
index 0000000..00239f6
--- /dev/null
+++ b/src/modulex_integrations/tools/revolt/README.md
@@ -0,0 +1,30 @@
+# Revolt
+
+Open-source chat platform for group management and social features via the Revolt REST API (`revolt.chat/api`).
+
+## Authentication
+
+### Session Token
+
+- Obtain your session token from the Revolt client (inspect network requests or use the bot token from your Revolt bot settings).
+- Required env var: `REVOLT_SESSION_TOKEN` (format: session token string).
+- The token is sent as the `x-session-token` header on every request.
+
+## Tools
+
+| name | description | required params |
+| --- | --- | --- |
+| `create_group` | Create a new group channel | `name` |
+| `add_group_member` | Add another user to a group channel | `target`, `member` |
+| `send_friend_request` | Send a friend request to another user | `username` |
+
+Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fills in from the resolved credential.
+
+## Limits & Quotas
+
+- No officially documented rate limits for the Revolt API.
+- Error model: non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising.
+
+## Maintainer
+
+ModuleX core team.
diff --git a/src/modulex_integrations/tools/revolt/__init__.py b/src/modulex_integrations/tools/revolt/__init__.py
new file mode 100644
index 0000000..10ff87e
--- /dev/null
+++ b/src/modulex_integrations/tools/revolt/__init__.py
@@ -0,0 +1,21 @@
+"""Revolt integration — discovered via the ``modulex.tools`` entry point."""
+from modulex_integrations.tools.revolt.manifest import manifest
+from modulex_integrations.tools.revolt.tools import (
+ add_group_member,
+ create_group,
+ send_friend_request,
+)
+
+TOOLS = (
+ create_group,
+ add_group_member,
+ send_friend_request,
+)
+
+__all__ = [
+ "TOOLS",
+ "add_group_member",
+ "create_group",
+ "manifest",
+ "send_friend_request",
+]
diff --git a/src/modulex_integrations/tools/revolt/dependencies.toml b/src/modulex_integrations/tools/revolt/dependencies.toml
new file mode 100644
index 0000000..0d40e86
--- /dev/null
+++ b/src/modulex_integrations/tools/revolt/dependencies.toml
@@ -0,0 +1,3 @@
+# Runtime dependencies for the revolt integration.
+# CI assembles this into the root pyproject's [project.optional-dependencies].
+dependencies = []
diff --git a/src/modulex_integrations/tools/revolt/manifest.py b/src/modulex_integrations/tools/revolt/manifest.py
new file mode 100644
index 0000000..29f399e
--- /dev/null
+++ b/src/modulex_integrations/tools/revolt/manifest.py
@@ -0,0 +1,107 @@
+"""Revolt integration manifest."""
+from __future__ import annotations
+
+from modulex_integrations.schema import (
+ ActionDefinition,
+ BearerTokenAuthSchema,
+ EnvVar,
+ IntegrationManifest,
+ ParameterDef,
+ SuccessIndicators,
+ TestEndpoint,
+)
+
+__all__ = ["manifest"]
+
+
+manifest = IntegrationManifest(
+ name="revolt",
+ display_name="Revolt",
+ description="Revolt open-source chat platform — group management and friend requests",
+ version="1.0.0",
+ author="ModuleX",
+ logo="modulex:revolt-themed",
+ app_url="https://revolt.chat",
+ categories=["Communication"],
+ actions=[
+ ActionDefinition(
+ name="create_group",
+ description="Create a new group channel",
+ parameters={
+ "name": ParameterDef(
+ type="string",
+ description="The name of the group",
+ required=True,
+ ),
+ "description": ParameterDef(
+ type="string",
+ description="Group description",
+ ),
+ "users": ParameterDef(
+ type="array",
+ description="IDs of the users to add to the group",
+ ),
+ "nsfw": ParameterDef(
+ type="boolean",
+ description="Whether this group is age-restricted",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="add_group_member",
+ description="Add another user to a group channel",
+ parameters={
+ "target": ParameterDef(
+ type="string",
+ description="ID of the group channel",
+ required=True,
+ ),
+ "member": ParameterDef(
+ type="string",
+ description="ID of the user to add",
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="send_friend_request",
+ description="Send a friend request to another user",
+ parameters={
+ "username": ParameterDef(
+ type="string",
+ description="Username and discriminator combo separated by #",
+ required=True,
+ ),
+ },
+ ),
+ ],
+ auth_schemas=[
+ BearerTokenAuthSchema(
+ display_name="Session Token",
+ description=(
+ "Authenticate using your Revolt session token"
+ " (sent as x-session-token header)"
+ ),
+ setup_environment_variables=[
+ EnvVar(
+ name="REVOLT_SESSION_TOKEN",
+ display_name="Session Token",
+ description="Your Revolt session token from the Revolt client",
+ required=True,
+ sensitive=True,
+ ),
+ ],
+ test_endpoint=TestEndpoint(
+ url="https://revolt.chat/api/users/@me",
+ method="GET",
+ headers={"x-session-token": "{token}"},
+ success_indicators=SuccessIndicators(
+ status_codes=[200],
+ response_fields=["_id"],
+ ),
+ cost_level="free",
+ description="Validates the session token by fetching the current user",
+ ),
+ ),
+ ],
+)
diff --git a/src/modulex_integrations/tools/revolt/outputs.py b/src/modulex_integrations/tools/revolt/outputs.py
new file mode 100644
index 0000000..6422dc4
--- /dev/null
+++ b/src/modulex_integrations/tools/revolt/outputs.py
@@ -0,0 +1,42 @@
+"""Pydantic response models for the revolt integration's @tool functions."""
+from __future__ import annotations
+
+from pydantic import BaseModel, ConfigDict
+
+__all__ = [
+ "AddGroupMemberOutput",
+ "CreateGroupOutput",
+ "SendFriendRequestOutput",
+]
+
+
+class _Base(BaseModel):
+ """Shared config for every output model in this integration."""
+
+ model_config = ConfigDict(extra="forbid")
+
+
+# --- Per-action output models ----------------------------------------------
+
+
+class CreateGroupOutput(_Base):
+ success: bool
+ error: str | None = None
+ channel_id: str | None = None
+ channel_type: str | None = None
+ name: str | None = None
+ description: str | None = None
+ owner: str | None = None
+ nsfw: bool | None = None
+
+
+class AddGroupMemberOutput(_Base):
+ success: bool
+ error: str | None = None
+
+
+class SendFriendRequestOutput(_Base):
+ success: bool
+ error: str | None = None
+ user_id: str | None = None
+ status: str | None = None
diff --git a/src/modulex_integrations/tools/revolt/tests/__init__.py b/src/modulex_integrations/tools/revolt/tests/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/modulex_integrations/tools/revolt/tests/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/modulex_integrations/tools/revolt/tests/test_revolt.py b/src/modulex_integrations/tools/revolt/tests/test_revolt.py
new file mode 100644
index 0000000..c02cf5a
--- /dev/null
+++ b/src/modulex_integrations/tools/revolt/tests/test_revolt.py
@@ -0,0 +1,128 @@
+"""Happy-path tests for every revolt @tool, plus a manifest sanity check."""
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from modulex_integrations.tools.revolt import (
+ TOOLS,
+ add_group_member,
+ create_group,
+ manifest,
+ send_friend_request,
+)
+from modulex_integrations.tools.revolt.outputs import (
+ AddGroupMemberOutput,
+ CreateGroupOutput,
+ SendFriendRequestOutput,
+)
+
+API = "https://revolt.chat/api"
+
+_AUTH: dict[str, Any] = {
+ "auth_type": "bearer_token",
+ "auth_data": {"token": "fake_session_token"},
+}
+
+
+def _args(**extra: Any) -> dict[str, Any]:
+ return dict(_AUTH, **extra)
+
+
+# --- Manifest sanity --------------------------------------------------------
+
+
+class TestManifest:
+ def test_manifest_exposes_3_actions(self) -> None:
+ assert len(manifest.actions) == 3
+
+ def test_manifest_actions_match_tools_tuple(self) -> None:
+ assert {a.name for a in manifest.actions} == {t.name for t in TOOLS}
+
+ def test_manifest_has_bearer_token_auth(self) -> None:
+ assert {a.auth_type for a in manifest.auth_schemas} == {"bearer_token"}
+
+
+# --- Per-action happy-path tests -------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_create_group(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/channels/create",
+ json={
+ # TODO: fill in a representative response shape from the Revolt API docs
+ "_id": "01ABCDEF",
+ "channel_type": "Group",
+ "name": "Test Group",
+ "owner": "01USER",
+ "nsfw": False,
+ },
+ )
+
+ result_dict = await create_group.ainvoke(_args(name="Test Group"))
+
+ assert isinstance(result_dict, dict)
+ result = CreateGroupOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.channel_id == "01ABCDEF"
+ assert result.name == "Test Group"
+
+
+@pytest.mark.asyncio
+async def test_add_group_member(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="PUT",
+ url=f"{API}/channels/01GROUP/recipients/01MEMBER",
+ status_code=204,
+ )
+
+ result_dict = await add_group_member.ainvoke(
+ _args(target="01GROUP", member="01MEMBER")
+ )
+
+ assert isinstance(result_dict, dict)
+ result = AddGroupMemberOutput.model_validate(result_dict)
+ assert result.success is True
+
+
+@pytest.mark.asyncio
+async def test_send_friend_request(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/users/friend",
+ json={
+ # TODO: fill in a representative response shape from the Revolt API docs
+ "_id": "01TARGET",
+ "status": "Outgoing",
+ },
+ )
+
+ result_dict = await send_friend_request.ainvoke(
+ _args(username="testuser#0001")
+ )
+
+ assert isinstance(result_dict, dict)
+ result = SendFriendRequestOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.user_id == "01TARGET"
+ assert result.status == "Outgoing"
+
+
+# --- Failure-path tests ----------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_create_group_empty_token(): # type: ignore[no-untyped-def]
+ """Empty credential should short-circuit without hitting the wire."""
+ result_dict = await create_group.ainvoke(
+ {"auth_type": "bearer_token", "auth_data": {"token": ""}, "name": "Test"}
+ )
+
+ assert isinstance(result_dict, dict)
+ result = CreateGroupOutput.model_validate(result_dict)
+ assert result.success is False
+ assert result.error is not None
+ assert "token" in result.error.lower()
diff --git a/src/modulex_integrations/tools/revolt/tools.py b/src/modulex_integrations/tools/revolt/tools.py
new file mode 100644
index 0000000..e26b8b9
--- /dev/null
+++ b/src/modulex_integrations/tools/revolt/tools.py
@@ -0,0 +1,186 @@
+"""Revolt LangChain @tool functions."""
+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.revolt.outputs import (
+ AddGroupMemberOutput,
+ CreateGroupOutput,
+ SendFriendRequestOutput,
+)
+
+__all__ = [
+ "add_group_member",
+ "create_group",
+ "send_friend_request",
+]
+
+_BASE_URL = "https://revolt.chat/api"
+_TIMEOUT = 30.0
+
+
+def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]:
+ """Build headers for the Revolt API using the session token."""
+ token = auth_data.get("token", "")
+ return {
+ "x-session-token": token,
+ "Content-Type": "application/json",
+ }
+
+
+# --- Input schemas --------------------------------------------------------
+
+
+class CreateGroupInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ name: str = Field(description="The name of the group")
+ description: str | None = Field(default=None, description="Group description")
+ users: list[str] | None = Field(
+ default=None, description="IDs of the users to add to the group"
+ )
+ nsfw: bool | None = Field(default=None, description="Whether this group is age-restricted")
+
+
+class AddGroupMemberInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ target: str = Field(description="ID of the group channel")
+ member: str = Field(description="ID of the user to add")
+
+
+class SendFriendRequestInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ username: str = Field(description="Username and discriminator combo separated by #")
+
+
+# --- @tool functions ------------------------------------------------------
+
+
+@tool(args_schema=CreateGroupInput)
+@serialize_pydantic_return
+async def create_group(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ name: str,
+ description: str | None = None,
+ users: list[str] | None = None,
+ nsfw: bool | None = None,
+) -> CreateGroupOutput:
+ """Create a new group channel."""
+ token = auth_data.get("token", "")
+ if not token or not token.strip():
+ return CreateGroupOutput(success=False, error="Missing or empty session token.")
+ headers = _get_auth_headers(auth_type, auth_data)
+ payload: dict[str, Any] = {"name": name}
+ if description is not None:
+ payload["description"] = description
+ if users is not None:
+ payload["users"] = users
+ if nsfw is not None:
+ payload["nsfw"] = nsfw
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.post(
+ f"{_BASE_URL}/channels/create",
+ headers=headers,
+ json=payload,
+ )
+ if response.status_code not in (200, 201):
+ return CreateGroupOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return CreateGroupOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return CreateGroupOutput(success=False, error=f"Call failed: {exc}")
+
+ return CreateGroupOutput(
+ success=True,
+ channel_id=data.get("_id"),
+ channel_type=data.get("channel_type"),
+ name=data.get("name"),
+ description=data.get("description"),
+ owner=data.get("owner"),
+ nsfw=data.get("nsfw"),
+ )
+
+
+@tool(args_schema=AddGroupMemberInput)
+@serialize_pydantic_return
+async def add_group_member(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ target: str,
+ member: str,
+) -> AddGroupMemberOutput:
+ """Add another user to a group channel."""
+ token = auth_data.get("token", "")
+ if not token or not token.strip():
+ return AddGroupMemberOutput(success=False, error="Missing or empty session token.")
+ headers = _get_auth_headers(auth_type, auth_data)
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.put(
+ f"{_BASE_URL}/channels/{target}/recipients/{member}",
+ headers=headers,
+ )
+ if response.status_code not in (200, 204):
+ return AddGroupMemberOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ except httpx.TimeoutException:
+ return AddGroupMemberOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return AddGroupMemberOutput(success=False, error=f"Call failed: {exc}")
+
+ return AddGroupMemberOutput(success=True)
+
+
+@tool(args_schema=SendFriendRequestInput)
+@serialize_pydantic_return
+async def send_friend_request(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ username: str,
+) -> SendFriendRequestOutput:
+ """Send a friend request to another user."""
+ token = auth_data.get("token", "")
+ if not token or not token.strip():
+ return SendFriendRequestOutput(success=False, error="Missing or empty session token.")
+ headers = _get_auth_headers(auth_type, auth_data)
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.post(
+ f"{_BASE_URL}/users/friend",
+ headers=headers,
+ json={"username": username},
+ )
+ if response.status_code not in (200, 201):
+ return SendFriendRequestOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return SendFriendRequestOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return SendFriendRequestOutput(success=False, error=f"Call failed: {exc}")
+
+ return SendFriendRequestOutput(
+ success=True,
+ user_id=data.get("_id"),
+ status=data.get("status"),
+ )
diff --git a/src/modulex_integrations/tools/square/README.md b/src/modulex_integrations/tools/square/README.md
new file mode 100644
index 0000000..212c8c2
--- /dev/null
+++ b/src/modulex_integrations/tools/square/README.md
@@ -0,0 +1,37 @@
+# Square
+
+Payment processing, commerce, and business management via the Square Connect API (`connect.squareup.com/v2`).
+
+## Authentication
+
+### OAuth2 Authentication (recommended)
+
+- Register an OAuth app at .
+- Redirect URI: `https://api.modulex.dev/credentials/oauth2/callback`
+- Scopes requested: `CUSTOMERS_WRITE`, `CUSTOMERS_READ`, `ORDERS_WRITE`, `ORDERS_READ`, `INVOICES_WRITE`, `INVOICES_READ`, `MERCHANT_PROFILE_READ`
+- Required env vars (only when bringing your own OAuth app):
+ - `SQUARE_OAUTH2_CLIENT_ID` (format: `sq0idp-xxxxxxxxxxxxxxxx`)
+ - `SQUARE_OAUTH2_CLIENT_SECRET` (format: `sq0csp-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`)
+
+## Tools
+
+| name | description | required params |
+| --- | --- | --- |
+| `create_customer` | Create a new customer for a business | _(at least one of given_name, family_name, company_name, email_address, phone_number)_ |
+| `create_invoice` | Create a draft invoice for an order | `location_id`, `order_id`, `customer_id`, `due_date`, `accepted_payment_methods` |
+| `create_order` | Create a new order with product line items | `location_id` |
+| `list_event_types_options` | Retrieve available webhook event types | _(none)_ |
+| `list_location_options` | Retrieve locations for the authenticated account | _(none)_ |
+| `send_invoice` | Publish the latest version of a specified invoice | `location_id`, `invoice_id` |
+
+Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fills in from the resolved OAuth credential.
+
+## Limits & Quotas
+
+- **Rate limits**: Square enforces per-endpoint rate limits, typically 30-100 requests per 30 seconds depending on the endpoint category.
+- **Sandbox**: Square provides a sandbox environment for testing at `connect.squareupsandbox.com`.
+- **Error model**: Non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising. Square errors include a JSON `errors[]` array with `category`, `code`, and `detail` fields.
+
+## Maintainer
+
+ModuleX core team.
diff --git a/src/modulex_integrations/tools/square/__init__.py b/src/modulex_integrations/tools/square/__init__.py
new file mode 100644
index 0000000..a7252ab
--- /dev/null
+++ b/src/modulex_integrations/tools/square/__init__.py
@@ -0,0 +1,30 @@
+"""Square integration — discovered via the ``modulex.tools`` entry point."""
+from modulex_integrations.tools.square.manifest import manifest
+from modulex_integrations.tools.square.tools import (
+ create_customer,
+ create_invoice,
+ create_order,
+ list_event_types_options,
+ list_location_options,
+ send_invoice,
+)
+
+TOOLS = (
+ create_customer,
+ create_invoice,
+ create_order,
+ list_event_types_options,
+ list_location_options,
+ send_invoice,
+)
+
+__all__ = [
+ "TOOLS",
+ "create_customer",
+ "create_invoice",
+ "create_order",
+ "list_event_types_options",
+ "list_location_options",
+ "manifest",
+ "send_invoice",
+]
diff --git a/src/modulex_integrations/tools/square/dependencies.toml b/src/modulex_integrations/tools/square/dependencies.toml
new file mode 100644
index 0000000..bea2095
--- /dev/null
+++ b/src/modulex_integrations/tools/square/dependencies.toml
@@ -0,0 +1,3 @@
+# Runtime dependencies for the square integration.
+# CI assembles this into the root pyproject's [project.optional-dependencies].
+dependencies = []
diff --git a/src/modulex_integrations/tools/square/manifest.py b/src/modulex_integrations/tools/square/manifest.py
new file mode 100644
index 0000000..d5b6190
--- /dev/null
+++ b/src/modulex_integrations/tools/square/manifest.py
@@ -0,0 +1,195 @@
+"""Square integration manifest."""
+from __future__ import annotations
+
+from modulex_integrations.schema import (
+ ActionDefinition,
+ EnvVar,
+ IntegrationManifest,
+ OAuth2AuthSchema,
+ OAuthConfig,
+ ParameterDef,
+ SuccessIndicators,
+ TestEndpoint,
+)
+
+__all__ = ["manifest"]
+
+
+manifest = IntegrationManifest(
+ name="square",
+ display_name="Square",
+ description="Payment processing, commerce, and business management platform",
+ version="1.0.0",
+ author="ModuleX",
+ logo="logos:square",
+ app_url="https://squareup.com",
+ categories=["payments", "commerce", "finance"],
+ actions=[
+ ActionDefinition(
+ name="create_customer",
+ description="Create a new customer for a business. Must provide at least one of: given_name, family_name, company_name, email_address, or phone_number.",
+ parameters={
+ "given_name": ParameterDef(
+ type="string",
+ description="The first name associated with the customer profile",
+ ),
+ "family_name": ParameterDef(
+ type="string",
+ description="The last name associated with the customer profile",
+ ),
+ "company_name": ParameterDef(
+ type="string",
+ description="A business name associated with the customer profile",
+ ),
+ "email_address": ParameterDef(
+ type="string",
+ description="The email address associated with the customer profile",
+ ),
+ "phone_number": ParameterDef(
+ type="string",
+ description="Phone number (9-16 digits, optional + prefix and country code)",
+ ),
+ "reference_id": ParameterDef(
+ type="string",
+ description="An optional second ID to associate the customer with an entity in another system",
+ ),
+ "note": ParameterDef(
+ type="string",
+ description="A custom note associated with the customer profile",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="create_invoice",
+ description="Create a draft invoice for an order. You must publish (send) the invoice before Square can process it.",
+ parameters={
+ "location_id": ParameterDef(
+ type="string",
+ description="The ID of the Square location",
+ required=True,
+ ),
+ "order_id": ParameterDef(
+ type="string",
+ description="The ID of the order associated with the invoice",
+ required=True,
+ ),
+ "customer_id": ParameterDef(
+ type="string",
+ description="The ID of the customer who will receive the invoice",
+ required=True,
+ ),
+ "due_date": ParameterDef(
+ type="string",
+ description="The due date for the payment request, in YYYY-MM-DD format",
+ required=True,
+ ),
+ "accepted_payment_methods": ParameterDef(
+ type="array",
+ description="Payment methods customers can use. Valid values: card, square_gift_card, bank_account, buy_now_pay_later, cash_app_pay",
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="create_order",
+ description="Create a new order that can include information about products for purchase.",
+ parameters={
+ "location_id": ParameterDef(
+ type="string",
+ description="The ID of the Square location for the order",
+ required=True,
+ ),
+ "customer_id": ParameterDef(
+ type="string",
+ description="The ID of the customer associated with the order",
+ ),
+ "reference_id": ParameterDef(
+ type="string",
+ description="An optional second ID to associate the order with an entity in another system",
+ ),
+ "line_items": ParameterDef(
+ type="object",
+ description="Line items for the order. Array of objects, each with: quantity (string), name (string), base_price_money ({amount: int in cents, currency: string e.g. 'USD'})",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="list_event_types_options",
+ description="Retrieve the list of available webhook event types from Square.",
+ parameters={},
+ ),
+ ActionDefinition(
+ name="list_location_options",
+ description="Retrieve the list of locations for the authenticated Square account.",
+ parameters={},
+ ),
+ ActionDefinition(
+ name="send_invoice",
+ description="Publish the latest version of a specified invoice so Square can process it.",
+ parameters={
+ "location_id": ParameterDef(
+ type="string",
+ description="The ID of the Square location",
+ required=True,
+ ),
+ "invoice_id": ParameterDef(
+ type="string",
+ description="The ID of the invoice to publish",
+ required=True,
+ ),
+ },
+ ),
+ ],
+ auth_schemas=[
+ OAuth2AuthSchema(
+ display_name="OAuth2 Authentication",
+ description="Connect using Square OAuth (recommended)",
+ setup_environment_variables=[
+ EnvVar(
+ name="SQUARE_OAUTH2_CLIENT_ID",
+ display_name="Client ID",
+ description="Square OAuth App Client ID",
+ required=True,
+ sensitive=False,
+ only_for_custom=True,
+ sample_format="sq0idp-xxxxxxxxxxxxxxxx",
+ about_url="https://developer.squareup.com/apps",
+ ),
+ EnvVar(
+ name="SQUARE_OAUTH2_CLIENT_SECRET",
+ display_name="Client Secret",
+ description="Square OAuth App Client Secret",
+ required=True,
+ sensitive=True,
+ only_for_custom=True,
+ sample_format="sq0csp-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ about_url="https://developer.squareup.com/apps",
+ ),
+ ],
+ oauth_config=OAuthConfig(
+ auth_url="https://connect.squareup.com/oauth2/authorize",
+ token_url="https://connect.squareup.com/oauth2/token",
+ scopes=[
+ "CUSTOMERS_WRITE",
+ "CUSTOMERS_READ",
+ "ORDERS_WRITE",
+ "ORDERS_READ",
+ "INVOICES_WRITE",
+ "INVOICES_READ",
+ "MERCHANT_PROFILE_READ",
+ ],
+ ),
+ test_endpoint=TestEndpoint(
+ url="https://connect.squareup.com/v2/locations",
+ method="GET",
+ headers={"Authorization": "Bearer {access_token}"},
+ success_indicators=SuccessIndicators(
+ status_codes=[200],
+ response_fields=["locations"],
+ ),
+ cost_level="free",
+ description="Validates OAuth token by listing locations",
+ ),
+ ),
+ ],
+)
diff --git a/src/modulex_integrations/tools/square/outputs.py b/src/modulex_integrations/tools/square/outputs.py
new file mode 100644
index 0000000..af3a20a
--- /dev/null
+++ b/src/modulex_integrations/tools/square/outputs.py
@@ -0,0 +1,97 @@
+"""Pydantic response models for the square integration's @tool functions."""
+from __future__ import annotations
+
+from pydantic import BaseModel, ConfigDict, Field
+
+__all__ = [
+ "CreateCustomerOutput",
+ "CreateInvoiceOutput",
+ "CreateOrderOutput",
+ "CustomerResource",
+ "InvoiceResource",
+ "ListEventTypesOptionsOutput",
+ "ListLocationOptionsOutput",
+ "LocationOption",
+ "OrderResource",
+ "SendInvoiceOutput",
+]
+
+
+class _Base(BaseModel):
+ """Shared config for every output model in this integration."""
+
+ model_config = ConfigDict(extra="forbid")
+
+
+class CustomerResource(_Base):
+ id: str | None = None
+ given_name: str | None = None
+ family_name: str | None = None
+ company_name: str | None = None
+ email_address: str | None = None
+ phone_number: str | None = None
+ reference_id: str | None = None
+ note: str | None = None
+ created_at: str | None = None
+ updated_at: str | None = None
+
+
+class InvoiceResource(_Base):
+ id: str | None = None
+ version: int | None = None
+ location_id: str | None = None
+ order_id: str | None = None
+ status: str | None = None
+ created_at: str | None = None
+ updated_at: str | None = None
+
+
+class OrderResource(_Base):
+ id: str | None = None
+ location_id: str | None = None
+ reference_id: str | None = None
+ state: str | None = None
+ created_at: str | None = None
+ updated_at: str | None = None
+
+
+class LocationOption(_Base):
+ id: str | None = None
+ name: str | None = None
+ status: str | None = None
+
+
+class CreateCustomerOutput(_Base):
+ success: bool
+ error: str | None = None
+ customer: CustomerResource | None = None
+
+
+class CreateInvoiceOutput(_Base):
+ success: bool
+ error: str | None = None
+ invoice: InvoiceResource | None = None
+
+
+class CreateOrderOutput(_Base):
+ success: bool
+ error: str | None = None
+ order: OrderResource | None = None
+
+
+class ListEventTypesOptionsOutput(_Base):
+ success: bool
+ error: str | None = None
+ event_types: list[str] = Field(default_factory=list)
+
+
+class ListLocationOptionsOutput(_Base):
+ success: bool
+ error: str | None = None
+ locations: list[LocationOption] = Field(default_factory=list)
+
+
+class SendInvoiceOutput(_Base):
+ success: bool
+ error: str | None = None
+ invoice: InvoiceResource | None = None
diff --git a/src/modulex_integrations/tools/square/tests/__init__.py b/src/modulex_integrations/tools/square/tests/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/modulex_integrations/tools/square/tests/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/modulex_integrations/tools/square/tests/test_square.py b/src/modulex_integrations/tools/square/tests/test_square.py
new file mode 100644
index 0000000..ee33493
--- /dev/null
+++ b/src/modulex_integrations/tools/square/tests/test_square.py
@@ -0,0 +1,237 @@
+"""Happy-path tests for every square @tool, plus a manifest sanity check."""
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from modulex_integrations.tools.square import (
+ TOOLS,
+ create_customer,
+ create_invoice,
+ create_order,
+ list_event_types_options,
+ list_location_options,
+ manifest,
+ send_invoice,
+)
+from modulex_integrations.tools.square.outputs import (
+ CreateCustomerOutput,
+ CreateInvoiceOutput,
+ CreateOrderOutput,
+ ListEventTypesOptionsOutput,
+ ListLocationOptionsOutput,
+ SendInvoiceOutput,
+)
+
+API = "https://connect.squareup.com/v2"
+
+_AUTH: dict[str, Any] = {
+ "auth_type": "oauth2",
+ "auth_data": {"access_token": "fake_access_token"},
+}
+
+
+def _args(**extra: Any) -> dict[str, Any]:
+ """Build a ``.ainvoke()`` input dict: auth + per-test extras."""
+ return dict(_AUTH, **extra)
+
+
+# --- Manifest sanity --------------------------------------------------------
+
+
+class TestManifest:
+ def test_manifest_exposes_6_actions(self) -> None:
+ assert len(manifest.actions) == 6
+
+ def test_manifest_actions_match_tools_tuple(self) -> None:
+ assert {a.name for a in manifest.actions} == {t.name for t in TOOLS}
+
+ def test_manifest_has_oauth2_auth(self) -> None:
+ assert {a.auth_type for a in manifest.auth_schemas} == {"oauth2"}
+
+
+# --- Per-action happy-path tests -------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_create_customer(httpx_mock) -> None: # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/customers",
+ json={
+ # TODO: fill in a representative response shape from the Square API docs
+ "customer": {
+ "id": "CUST123",
+ "given_name": "John",
+ "family_name": "Doe",
+ "email_address": "john@example.com",
+ "created_at": "2024-01-01T00:00:00Z",
+ "updated_at": "2024-01-01T00:00:00Z",
+ },
+ },
+ )
+
+ result_dict = await create_customer.ainvoke(_args(given_name="John", family_name="Doe", email_address="john@example.com"))
+
+ assert isinstance(result_dict, dict)
+ result = CreateCustomerOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.customer is not None
+ assert result.customer.id == "CUST123"
+
+
+@pytest.mark.asyncio
+async def test_create_invoice(httpx_mock) -> None: # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/invoices",
+ json={
+ # TODO: fill in a representative response shape from the Square API docs
+ "invoice": {
+ "id": "INV123",
+ "version": 0,
+ "location_id": "LOC1",
+ "order_id": "ORD1",
+ "status": "DRAFT",
+ "created_at": "2024-01-01T00:00:00Z",
+ "updated_at": "2024-01-01T00:00:00Z",
+ },
+ },
+ )
+
+ result_dict = await create_invoice.ainvoke(
+ _args(
+ location_id="LOC1",
+ order_id="ORD1",
+ customer_id="CUST1",
+ due_date="2024-02-01",
+ accepted_payment_methods=["card", "bank_account"],
+ )
+ )
+
+ assert isinstance(result_dict, dict)
+ result = CreateInvoiceOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.invoice is not None
+ assert result.invoice.id == "INV123"
+
+
+@pytest.mark.asyncio
+async def test_create_order(httpx_mock) -> None: # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/orders",
+ json={
+ # TODO: fill in a representative response shape from the Square API docs
+ "order": {
+ "id": "ORD123",
+ "location_id": "LOC1",
+ "state": "OPEN",
+ "created_at": "2024-01-01T00:00:00Z",
+ "updated_at": "2024-01-01T00:00:00Z",
+ },
+ },
+ )
+
+ result_dict = await create_order.ainvoke(_args(location_id="LOC1"))
+
+ assert isinstance(result_dict, dict)
+ result = CreateOrderOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.order is not None
+ assert result.order.id == "ORD123"
+
+
+@pytest.mark.asyncio
+async def test_list_event_types_options(httpx_mock) -> None: # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/webhooks/event-types",
+ json={
+ # TODO: fill in a representative response shape from the Square API docs
+ "event_types": ["payment.created", "payment.updated", "order.created"],
+ },
+ )
+
+ result_dict = await list_event_types_options.ainvoke(_args())
+
+ assert isinstance(result_dict, dict)
+ result = ListEventTypesOptionsOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.event_types) == 3
+
+
+@pytest.mark.asyncio
+async def test_list_location_options(httpx_mock) -> None: # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/locations",
+ json={
+ # TODO: fill in a representative response shape from the Square API docs
+ "locations": [
+ {"id": "LOC1", "name": "Main Store", "status": "ACTIVE"},
+ {"id": "LOC2", "name": "Warehouse", "status": "ACTIVE"},
+ ],
+ },
+ )
+
+ result_dict = await list_location_options.ainvoke(_args())
+
+ assert isinstance(result_dict, dict)
+ result = ListLocationOptionsOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.locations) == 2
+ assert result.locations[0].id == "LOC1"
+
+
+@pytest.mark.asyncio
+async def test_send_invoice(httpx_mock) -> None: # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/invoices/INV123",
+ json={
+ "invoice": {
+ "id": "INV123",
+ "version": 1,
+ "status": "DRAFT",
+ },
+ },
+ )
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/invoices/INV123/publish",
+ json={
+ # TODO: fill in a representative response shape from the Square API docs
+ "invoice": {
+ "id": "INV123",
+ "version": 2,
+ "location_id": "LOC1",
+ "order_id": "ORD1",
+ "status": "PUBLISHED",
+ "created_at": "2024-01-01T00:00:00Z",
+ "updated_at": "2024-01-02T00:00:00Z",
+ },
+ },
+ )
+
+ result_dict = await send_invoice.ainvoke(_args(location_id="LOC1", invoice_id="INV123"))
+
+ assert isinstance(result_dict, dict)
+ result = SendInvoiceOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.invoice is not None
+ assert result.invoice.status == "PUBLISHED"
+
+
+@pytest.mark.asyncio
+async def test_create_customer_empty_credentials() -> None:
+ """Verify that empty credentials return an inline error without hitting the network."""
+ result_dict = await create_customer.ainvoke(
+ _args(auth_data={"access_token": ""}, given_name="Test")
+ )
+ assert isinstance(result_dict, dict)
+ result = CreateCustomerOutput.model_validate(result_dict)
+ assert result.success is False
+ assert result.error is not None
+ assert "access token" in result.error.lower()
diff --git a/src/modulex_integrations/tools/square/tools.py b/src/modulex_integrations/tools/square/tools.py
new file mode 100644
index 0000000..adc4d66
--- /dev/null
+++ b/src/modulex_integrations/tools/square/tools.py
@@ -0,0 +1,444 @@
+"""Square LangChain @tool functions."""
+from __future__ import annotations
+
+import uuid
+from typing import Any
+from urllib.parse import quote
+
+import httpx
+from langchain_core.tools import tool
+from pydantic import BaseModel, Field
+
+from modulex_integrations import serialize_pydantic_return
+from modulex_integrations.tools.square.outputs import (
+ CreateCustomerOutput,
+ CreateInvoiceOutput,
+ CreateOrderOutput,
+ CustomerResource,
+ InvoiceResource,
+ ListEventTypesOptionsOutput,
+ ListLocationOptionsOutput,
+ LocationOption,
+ OrderResource,
+ SendInvoiceOutput,
+)
+
+__all__ = [
+ "create_customer",
+ "create_invoice",
+ "create_order",
+ "list_event_types_options",
+ "list_location_options",
+ "send_invoice",
+]
+
+_BASE_URL = "https://connect.squareup.com/v2"
+_TIMEOUT = 30.0
+
+
+def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]:
+ """Build headers for the Square API based on auth_type/auth_data."""
+ headers: dict[str, str] = {
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ }
+ if auth_type == "oauth2":
+ access_token = auth_data.get("access_token")
+ if access_token:
+ headers["Authorization"] = f"Bearer {access_token}"
+ return headers
+
+
+# --- Input schemas --------------------------------------------------------
+
+
+class CreateCustomerInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ given_name: str | None = Field(default=None, description="The first name associated with the customer profile")
+ family_name: str | None = Field(default=None, description="The last name associated with the customer profile")
+ company_name: str | None = Field(default=None, description="A business name associated with the customer profile")
+ email_address: str | None = Field(default=None, description="The email address associated with the customer profile")
+ phone_number: str | None = Field(default=None, description="Phone number (9-16 digits, optional + prefix and country code)")
+ reference_id: str | None = Field(default=None, description="An optional second ID to associate the customer with an entity in another system")
+ note: str | None = Field(default=None, description="A custom note associated with the customer profile")
+
+
+class CreateInvoiceInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ location_id: str = Field(description="The ID of the Square location")
+ order_id: str = Field(description="The ID of the order associated with the invoice")
+ customer_id: str = Field(description="The ID of the customer who will receive the invoice")
+ due_date: str = Field(description="The due date for the payment request, in YYYY-MM-DD format")
+ accepted_payment_methods: list[str] = Field(description="Payment methods customers can use. Valid values: card, square_gift_card, bank_account, buy_now_pay_later, cash_app_pay")
+
+
+class CreateOrderInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ location_id: str = Field(description="The ID of the Square location for the order")
+ customer_id: str | None = Field(default=None, description="The ID of the customer associated with the order")
+ reference_id: str | None = Field(default=None, description="An optional second ID to associate the order with an entity in another system")
+ line_items: list[dict[str, Any]] | None = Field(default=None, description="Line items for the order. Array of objects, each with: quantity (string), name (string), base_price_money ({amount: int in cents, currency: string e.g. 'USD'})")
+
+
+class ListEventTypesOptionsInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+
+
+class ListLocationOptionsInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+
+
+class SendInvoiceInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ location_id: str = Field(description="The ID of the Square location")
+ invoice_id: str = Field(description="The ID of the invoice to publish")
+
+
+# --- @tool functions ------------------------------------------------------
+
+
+@tool(args_schema=CreateCustomerInput)
+@serialize_pydantic_return
+async def create_customer(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ given_name: str | None = None,
+ family_name: str | None = None,
+ company_name: str | None = None,
+ email_address: str | None = None,
+ phone_number: str | None = None,
+ reference_id: str | None = None,
+ note: str | None = None,
+) -> CreateCustomerOutput:
+ """Create a new customer for a business. Must provide at least one of: given_name, family_name, company_name, email_address, or phone_number."""
+ access_token = auth_data.get("access_token")
+ if not access_token or not access_token.strip():
+ return CreateCustomerOutput(success=False, error="Missing OAuth access token.")
+ headers = _get_auth_headers(auth_type, auth_data)
+ body: dict[str, Any] = {"idempotency_key": str(uuid.uuid4())}
+ if given_name:
+ body["given_name"] = given_name
+ if family_name:
+ body["family_name"] = family_name
+ if company_name:
+ body["company_name"] = company_name
+ if email_address:
+ body["email_address"] = email_address
+ if phone_number:
+ body["phone_number"] = phone_number
+ if reference_id:
+ body["reference_id"] = reference_id
+ if note:
+ body["note"] = note
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.post(
+ f"{_BASE_URL}/customers",
+ headers=headers,
+ json=body,
+ )
+ if response.status_code != 200:
+ return CreateCustomerOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return CreateCustomerOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return CreateCustomerOutput(success=False, error=f"Call failed: {exc}")
+
+ c = data.get("customer", {})
+ return CreateCustomerOutput(
+ success=True,
+ customer=CustomerResource(
+ id=c.get("id"),
+ given_name=c.get("given_name"),
+ family_name=c.get("family_name"),
+ company_name=c.get("company_name"),
+ email_address=c.get("email_address"),
+ phone_number=c.get("phone_number"),
+ reference_id=c.get("reference_id"),
+ note=c.get("note"),
+ created_at=c.get("created_at"),
+ updated_at=c.get("updated_at"),
+ ),
+ )
+
+
+@tool(args_schema=CreateInvoiceInput)
+@serialize_pydantic_return
+async def create_invoice(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ location_id: str,
+ order_id: str,
+ customer_id: str,
+ due_date: str,
+ accepted_payment_methods: list[str],
+) -> CreateInvoiceOutput:
+ """Create a draft invoice for an order. You must publish (send) the invoice before Square can process it."""
+ access_token = auth_data.get("access_token")
+ if not access_token or not access_token.strip():
+ return CreateInvoiceOutput(success=False, error="Missing OAuth access token.")
+ headers = _get_auth_headers(auth_type, auth_data)
+ payment_methods_obj: dict[str, bool] = {}
+ for method in accepted_payment_methods:
+ payment_methods_obj[method] = True
+
+ body: dict[str, Any] = {
+ "idempotency_key": str(uuid.uuid4()),
+ "invoice": {
+ "location_id": location_id,
+ "order_id": order_id,
+ "primary_recipient": {"customer_id": customer_id},
+ "payment_requests": [
+ {
+ "request_type": "BALANCE",
+ "due_date": due_date,
+ "automatic_payment_source": "NONE",
+ "reminders": [],
+ },
+ ],
+ "delivery_method": "EMAIL",
+ "accepted_payment_methods": payment_methods_obj,
+ },
+ }
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.post(
+ f"{_BASE_URL}/invoices",
+ headers=headers,
+ json=body,
+ )
+ if response.status_code != 200:
+ return CreateInvoiceOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return CreateInvoiceOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return CreateInvoiceOutput(success=False, error=f"Call failed: {exc}")
+
+ inv = data.get("invoice", {})
+ return CreateInvoiceOutput(
+ success=True,
+ invoice=InvoiceResource(
+ id=inv.get("id"),
+ version=inv.get("version"),
+ location_id=inv.get("location_id"),
+ order_id=inv.get("order_id"),
+ status=inv.get("status"),
+ created_at=inv.get("created_at"),
+ updated_at=inv.get("updated_at"),
+ ),
+ )
+
+
+@tool(args_schema=CreateOrderInput)
+@serialize_pydantic_return
+async def create_order(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ location_id: str,
+ customer_id: str | None = None,
+ reference_id: str | None = None,
+ line_items: list[dict[str, Any]] | None = None,
+) -> CreateOrderOutput:
+ """Create a new order that can include information about products for purchase."""
+ access_token = auth_data.get("access_token")
+ if not access_token or not access_token.strip():
+ return CreateOrderOutput(success=False, error="Missing OAuth access token.")
+ headers = _get_auth_headers(auth_type, auth_data)
+ order: dict[str, Any] = {"location_id": location_id}
+ if customer_id:
+ order["customer_id"] = customer_id
+ if reference_id:
+ order["reference_id"] = reference_id
+ if line_items:
+ order["line_items"] = line_items
+
+ body: dict[str, Any] = {
+ "idempotency_key": str(uuid.uuid4()),
+ "order": order,
+ }
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.post(
+ f"{_BASE_URL}/orders",
+ headers=headers,
+ json=body,
+ )
+ if response.status_code != 200:
+ return CreateOrderOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return CreateOrderOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return CreateOrderOutput(success=False, error=f"Call failed: {exc}")
+
+ o = data.get("order", {})
+ return CreateOrderOutput(
+ success=True,
+ order=OrderResource(
+ id=o.get("id"),
+ location_id=o.get("location_id"),
+ reference_id=o.get("reference_id"),
+ state=o.get("state"),
+ created_at=o.get("created_at"),
+ updated_at=o.get("updated_at"),
+ ),
+ )
+
+
+@tool(args_schema=ListEventTypesOptionsInput)
+@serialize_pydantic_return
+async def list_event_types_options(
+ auth_type: str,
+ auth_data: dict[str, Any],
+) -> ListEventTypesOptionsOutput:
+ """Retrieve the list of available webhook event types from Square."""
+ access_token = auth_data.get("access_token")
+ if not access_token or not access_token.strip():
+ return ListEventTypesOptionsOutput(success=False, error="Missing OAuth access token.")
+ headers = _get_auth_headers(auth_type, auth_data)
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{_BASE_URL}/webhooks/event-types",
+ headers=headers,
+ )
+ if response.status_code != 200:
+ return ListEventTypesOptionsOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return ListEventTypesOptionsOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return ListEventTypesOptionsOutput(success=False, error=f"Call failed: {exc}")
+
+ return ListEventTypesOptionsOutput(
+ success=True,
+ event_types=data.get("event_types", []),
+ )
+
+
+@tool(args_schema=ListLocationOptionsInput)
+@serialize_pydantic_return
+async def list_location_options(
+ auth_type: str,
+ auth_data: dict[str, Any],
+) -> ListLocationOptionsOutput:
+ """Retrieve the list of locations for the authenticated Square account."""
+ access_token = auth_data.get("access_token")
+ if not access_token or not access_token.strip():
+ return ListLocationOptionsOutput(success=False, error="Missing OAuth access token.")
+ headers = _get_auth_headers(auth_type, auth_data)
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{_BASE_URL}/locations",
+ headers=headers,
+ )
+ if response.status_code != 200:
+ return ListLocationOptionsOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return ListLocationOptionsOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return ListLocationOptionsOutput(success=False, error=f"Call failed: {exc}")
+
+ locations_data = data.get("locations", [])
+ locations = [
+ LocationOption(
+ id=loc.get("id"),
+ name=loc.get("name"),
+ status=loc.get("status"),
+ )
+ for loc in locations_data
+ ]
+ return ListLocationOptionsOutput(success=True, locations=locations)
+
+
+@tool(args_schema=SendInvoiceInput)
+@serialize_pydantic_return
+async def send_invoice(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ location_id: str,
+ invoice_id: str,
+) -> SendInvoiceOutput:
+ """Publish the latest version of a specified invoice so Square can process it."""
+ access_token = auth_data.get("access_token")
+ if not access_token or not access_token.strip():
+ return SendInvoiceOutput(success=False, error="Missing OAuth access token.")
+ headers = _get_auth_headers(auth_type, auth_data)
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ get_response = await client.get(
+ f"{_BASE_URL}/invoices/{quote(invoice_id, safe='')}",
+ headers=headers,
+ )
+ if get_response.status_code != 200:
+ return SendInvoiceOutput(
+ success=False,
+ error=f"Failed to retrieve invoice ({get_response.status_code}): {get_response.text}",
+ )
+ invoice_data = get_response.json().get("invoice", {})
+ version = invoice_data.get("version", 0)
+
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.post(
+ f"{_BASE_URL}/invoices/{quote(invoice_id, safe='')}/publish",
+ headers=headers,
+ json={
+ "idempotency_key": str(uuid.uuid4()),
+ "version": version,
+ },
+ )
+ if response.status_code != 200:
+ return SendInvoiceOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return SendInvoiceOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return SendInvoiceOutput(success=False, error=f"Call failed: {exc}")
+
+ inv = data.get("invoice", {})
+ return SendInvoiceOutput(
+ success=True,
+ invoice=InvoiceResource(
+ id=inv.get("id"),
+ version=inv.get("version"),
+ location_id=inv.get("location_id"),
+ order_id=inv.get("order_id"),
+ status=inv.get("status"),
+ created_at=inv.get("created_at"),
+ updated_at=inv.get("updated_at"),
+ ),
+ )
diff --git a/src/modulex_integrations/tools/woocommerce/README.md b/src/modulex_integrations/tools/woocommerce/README.md
new file mode 100644
index 0000000..1ab1981
--- /dev/null
+++ b/src/modulex_integrations/tools/woocommerce/README.md
@@ -0,0 +1,51 @@
+# WooCommerce
+
+Manage orders, products, customers, refunds, and payment methods on self-hosted WooCommerce stores via the WooCommerce REST API (`{store_url}/wp-json/wc/v3`).
+
+## Authentication
+
+### WooCommerce REST API Credentials
+
+- Go to your WordPress admin panel > WooCommerce > Settings > Advanced > REST API.
+- Click "Add key", give it a description, choose Read/Write permissions, and generate.
+- Required env vars:
+ - `WOOCOMMERCE_STORE_URL` — your store's base URL (e.g. `https://mystore.com`)
+ - `WOOCOMMERCE_CONSUMER_KEY` — format: `ck_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`
+ - `WOOCOMMERCE_CONSUMER_SECRET` — format: `cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`
+- Note: The store must be served over HTTPS for this integration to work (uses HTTP Basic Auth). HTTP-only stores requiring OAuth 1.0a signing are not supported.
+- Docs:
+
+## Tools
+
+| name | description | required params |
+| --- | --- | --- |
+| `create_order` | Create a new order in the WooCommerce store. | — |
+| `get_order` | Retrieve a specific order by ID. | `order_id` |
+| `list_orders` | Retrieve a list of orders with optional filters. | — |
+| `delete_order` | Delete an existing order. | `order_id` |
+| `update_order_status` | Update the status of a specific order. | `order_id`, `status` |
+| `create_product` | Create a new product in the WooCommerce store. | `name` |
+| `update_product` | Update an existing product. | `product_id` |
+| `get_product` | Retrieve a specific product by ID. | `product_id` |
+| `list_products` | Retrieve a list of products with optional filters. | — |
+| `search_customers` | Search for customers by email, name, or other criteria. | — |
+| `get_customer` | Retrieve a specific customer by ID. | `customer_id` |
+| `create_customer` | Create a new customer. | `email` |
+| `add_order_note` | Create a new note for an order. | `order_id`, `note` |
+| `get_order_note` | Retrieve a specific order note. | `order_id`, `note_id` |
+| `list_order_notes` | Retrieve all notes for a specific order. | `order_id` |
+| `create_refund` | Create a new refund for an order. | `order_id` |
+| `list_payment_method_options` | Retrieve available payment gateway options. | — |
+
+Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fills in from the resolved credential (custom auth with store_url, consumer_key, and consumer_secret).
+
+## Limits & Quotas
+
+- WooCommerce REST API rate limits depend on the hosting provider and server configuration. Most managed hosts enforce 60-120 requests/minute.
+- Pagination is server-controlled; responses include `X-WP-Total` and `X-WP-TotalPages` headers.
+- Error model: non-2xx responses are caught and returned as `success=False` + `error` rather than raising. Plan for retries on the agent side based on the error string.
+- No per-request pricing — WooCommerce is self-hosted.
+
+## Maintainer
+
+ModuleX core team.
diff --git a/src/modulex_integrations/tools/woocommerce/__init__.py b/src/modulex_integrations/tools/woocommerce/__init__.py
new file mode 100644
index 0000000..42b7072
--- /dev/null
+++ b/src/modulex_integrations/tools/woocommerce/__init__.py
@@ -0,0 +1,63 @@
+"""WooCommerce integration — discovered via the ``modulex.tools`` entry point."""
+from modulex_integrations.tools.woocommerce.manifest import manifest
+from modulex_integrations.tools.woocommerce.tools import (
+ add_order_note,
+ create_customer,
+ create_order,
+ create_product,
+ create_refund,
+ delete_order,
+ get_customer,
+ get_order,
+ get_order_note,
+ get_product,
+ list_order_notes,
+ list_orders,
+ list_payment_method_options,
+ list_products,
+ search_customers,
+ update_order_status,
+ update_product,
+)
+
+TOOLS = (
+ create_order,
+ get_order,
+ list_orders,
+ delete_order,
+ update_order_status,
+ create_product,
+ update_product,
+ get_product,
+ list_products,
+ search_customers,
+ get_customer,
+ create_customer,
+ add_order_note,
+ get_order_note,
+ list_order_notes,
+ create_refund,
+ list_payment_method_options,
+)
+
+__all__ = [
+ "TOOLS",
+ "add_order_note",
+ "create_customer",
+ "create_order",
+ "create_product",
+ "create_refund",
+ "delete_order",
+ "get_customer",
+ "get_order",
+ "get_order_note",
+ "get_product",
+ "list_order_notes",
+ "list_orders",
+ "list_payment_method_options",
+ "list_products",
+ "manifest",
+ "search_customers",
+ "update_order_status",
+ "update_product",
+]
diff --git a/src/modulex_integrations/tools/woocommerce/dependencies.toml b/src/modulex_integrations/tools/woocommerce/dependencies.toml
new file mode 100644
index 0000000..2c1cd9b
--- /dev/null
+++ b/src/modulex_integrations/tools/woocommerce/dependencies.toml
@@ -0,0 +1,3 @@
+# Runtime dependencies for the woocommerce integration.
+# CI assembles this into the root pyproject's [project.optional-dependencies].
+dependencies = []
diff --git a/src/modulex_integrations/tools/woocommerce/manifest.py b/src/modulex_integrations/tools/woocommerce/manifest.py
new file mode 100644
index 0000000..4b67efc
--- /dev/null
+++ b/src/modulex_integrations/tools/woocommerce/manifest.py
@@ -0,0 +1,432 @@
+"""WooCommerce integration manifest."""
+from __future__ import annotations
+
+from modulex_integrations.schema import (
+ ActionDefinition,
+ CustomAuthSchema,
+ EnvVar,
+ IntegrationManifest,
+ ParameterDef,
+)
+
+__all__ = ["manifest"]
+
+
+manifest = IntegrationManifest(
+ name="woocommerce",
+ display_name="WooCommerce",
+ description="WooCommerce REST API integration for managing orders, products, customers, and refunds on self-hosted WooCommerce stores.",
+ version="1.0.0",
+ author="ModuleX",
+ logo="logos:woocommerce-icon",
+ app_url="https://woocommerce.com",
+ categories=["E-Commerce", "Retail", "Payments"],
+ actions=[
+ ActionDefinition(
+ name="create_order",
+ description="Create a new order in the WooCommerce store.",
+ parameters={
+ "status": ParameterDef(
+ type="string",
+ description="Order status. Options: pending, processing, on-hold, completed, cancelled, refunded, failed, trash",
+ default="pending",
+ ),
+ "customer_id": ParameterDef(
+ type="integer",
+ description="User ID who owns the order. 0 for guests",
+ ),
+ "payment_method": ParameterDef(
+ type="string",
+ description="Payment method ID (e.g. bacs, cheque, cod, paypal)",
+ ),
+ "line_items": ParameterDef(
+ type="array",
+ description="Array of line item objects, each with product_id (integer) and quantity (integer)",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="get_order",
+ description="Retrieve a specific order by ID.",
+ parameters={
+ "order_id": ParameterDef(
+ type="integer",
+ description="ID of the order to retrieve",
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="list_orders",
+ description="Retrieve a list of orders with optional filters.",
+ parameters={
+ "search": ParameterDef(
+ type="string",
+ description="Limit results to those matching a string",
+ ),
+ "status": ParameterDef(
+ type="string",
+ description="Order status filter. Options: pending, processing, on-hold, completed, cancelled, refunded, failed, trash",
+ default="pending",
+ ),
+ "customer": ParameterDef(
+ type="integer",
+ description="Filter by customer user ID. 0 for guests",
+ ),
+ "after": ParameterDef(
+ type="string",
+ description="Limit to orders created after this ISO8601 date (e.g. 2023-01-01T00:00:00)",
+ ),
+ "before": ParameterDef(
+ type="string",
+ description="Limit to orders created before this ISO8601 date (e.g. 2023-12-31T23:59:59)",
+ ),
+ "max_results": ParameterDef(
+ type="integer",
+ description="Maximum number of results to return",
+ default=20,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="delete_order",
+ description="Delete an existing order.",
+ parameters={
+ "order_id": ParameterDef(
+ type="integer",
+ description="ID of the order to delete",
+ required=True,
+ ),
+ "force": ParameterDef(
+ type="boolean",
+ description="Whether to bypass trash and permanently delete the order",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="update_order_status",
+ description="Update the status of a specific order.",
+ parameters={
+ "order_id": ParameterDef(
+ type="integer",
+ description="ID of the order to update",
+ required=True,
+ ),
+ "status": ParameterDef(
+ type="string",
+ description="New order status. Options: pending, processing, on-hold, completed, cancelled, refunded, failed, trash",
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="create_product",
+ description="Create a new product in the WooCommerce store.",
+ parameters={
+ "name": ParameterDef(
+ type="string",
+ description="Name of the product",
+ required=True,
+ ),
+ "type": ParameterDef(
+ type="string",
+ description="Product type. Options: simple, grouped, external, variable",
+ default="simple",
+ ),
+ "status": ParameterDef(
+ type="string",
+ description="Product status. Options: draft, pending, private, publish",
+ default="publish",
+ ),
+ "regular_price": ParameterDef(
+ type="string",
+ description="Product regular price",
+ ),
+ "sale_price": ParameterDef(
+ type="string",
+ description="Product sale price",
+ ),
+ "description": ParameterDef(
+ type="string",
+ description="Product description (HTML allowed)",
+ ),
+ "categories": ParameterDef(
+ type="array",
+ description="Array of category IDs (integers) to assign the product to",
+ ),
+ "image_url": ParameterDef(
+ type="string",
+ description="URL of an image to add to the product",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="update_product",
+ description="Update an existing product.",
+ parameters={
+ "product_id": ParameterDef(
+ type="integer",
+ description="ID of the product to update",
+ required=True,
+ ),
+ "name": ParameterDef(
+ type="string",
+ description="New name for the product",
+ ),
+ "type": ParameterDef(
+ type="string",
+ description="Product type. Options: simple, grouped, external, variable",
+ ),
+ "status": ParameterDef(
+ type="string",
+ description="Product status. Options: draft, pending, private, publish",
+ ),
+ "regular_price": ParameterDef(
+ type="string",
+ description="Product regular price",
+ ),
+ "sale_price": ParameterDef(
+ type="string",
+ description="Product sale price",
+ ),
+ "description": ParameterDef(
+ type="string",
+ description="Product description (HTML allowed)",
+ ),
+ "categories": ParameterDef(
+ type="array",
+ description="Array of category IDs (integers) to assign the product to",
+ ),
+ "image_url": ParameterDef(
+ type="string",
+ description="URL of an image to add to the product",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="get_product",
+ description="Retrieve a specific product by ID.",
+ parameters={
+ "product_id": ParameterDef(
+ type="integer",
+ description="ID of the product to retrieve",
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="list_products",
+ description="Retrieve a list of products with optional filters.",
+ parameters={
+ "search": ParameterDef(
+ type="string",
+ description="Limit results to those matching a string",
+ ),
+ "status": ParameterDef(
+ type="string",
+ description="Product status filter. Options: draft, pending, private, publish",
+ default="publish",
+ ),
+ "type": ParameterDef(
+ type="string",
+ description="Product type filter. Options: simple, grouped, external, variable",
+ default="simple",
+ ),
+ "after": ParameterDef(
+ type="string",
+ description="Limit to products created after this ISO8601 date",
+ ),
+ "before": ParameterDef(
+ type="string",
+ description="Limit to products created before this ISO8601 date",
+ ),
+ "max_results": ParameterDef(
+ type="integer",
+ description="Maximum number of results to return",
+ default=20,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="search_customers",
+ description="Search for customers by email, name, or other criteria.",
+ parameters={
+ "search": ParameterDef(
+ type="string",
+ description="Limit results to those matching a string",
+ ),
+ "email": ParameterDef(
+ type="string",
+ description="Filter by exact customer email address",
+ ),
+ "role": ParameterDef(
+ type="string",
+ description="Filter by role. Options: all, administrator, editor, author, contributor, subscriber, customer",
+ default="customer",
+ ),
+ "max_results": ParameterDef(
+ type="integer",
+ description="Maximum number of results to return",
+ default=20,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="get_customer",
+ description="Retrieve a specific customer by ID.",
+ parameters={
+ "customer_id": ParameterDef(
+ type="integer",
+ description="ID of the customer to retrieve",
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="create_customer",
+ description="Create a new customer.",
+ parameters={
+ "email": ParameterDef(
+ type="string",
+ description="Customer email address",
+ required=True,
+ ),
+ "first_name": ParameterDef(
+ type="string",
+ description="Customer first name",
+ ),
+ "last_name": ParameterDef(
+ type="string",
+ description="Customer last name",
+ ),
+ "username": ParameterDef(
+ type="string",
+ description="Customer login username",
+ ),
+ "password": ParameterDef(
+ type="string",
+ description="Customer password",
+ ),
+ "is_paying_customer": ParameterDef(
+ type="boolean",
+ description="Whether the customer is a paying customer",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="add_order_note",
+ description="Create a new note for an order.",
+ parameters={
+ "order_id": ParameterDef(
+ type="integer",
+ description="ID of the order to add a note to",
+ required=True,
+ ),
+ "note": ParameterDef(
+ type="string",
+ description="Content of the order note",
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="get_order_note",
+ description="Retrieve a specific order note.",
+ parameters={
+ "order_id": ParameterDef(
+ type="integer",
+ description="ID of the order",
+ required=True,
+ ),
+ "note_id": ParameterDef(
+ type="integer",
+ description="ID of the order note",
+ required=True,
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="list_order_notes",
+ description="Retrieve all notes for a specific order.",
+ parameters={
+ "order_id": ParameterDef(
+ type="integer",
+ description="ID of the order",
+ required=True,
+ ),
+ "type": ParameterDef(
+ type="string",
+ description="Filter by note type. Options: any, customer, internal",
+ default="any",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="create_refund",
+ description="Create a new refund for an order.",
+ parameters={
+ "order_id": ParameterDef(
+ type="integer",
+ description="ID of the order to refund",
+ required=True,
+ ),
+ "amount": ParameterDef(
+ type="string",
+ description="Refund amount. If not specified, calculated from line items",
+ ),
+ "reason": ParameterDef(
+ type="string",
+ description="Reason for the refund",
+ ),
+ "api_refund": ParameterDef(
+ type="boolean",
+ description="When true, the payment gateway API generates the refund. When false, the refund is manual",
+ ),
+ "line_items": ParameterDef(
+ type="array",
+ description="Array of line item refund objects. Each with id (integer), refund_total (string), and optionally refund_tax (array)",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="list_payment_method_options",
+ description="Retrieve available payment gateway options.",
+ parameters={},
+ ),
+ ],
+ auth_schemas=[
+ CustomAuthSchema(
+ display_name="WooCommerce REST API Credentials",
+ description="Authenticate using your WooCommerce store URL, consumer key, and consumer secret via HTTP Basic Auth (HTTPS required).",
+ setup_environment_variables=[
+ EnvVar(
+ name="WOOCOMMERCE_STORE_URL",
+ display_name="Store URL",
+ description="Your WooCommerce store URL (e.g. https://mystore.com)",
+ required=True,
+ sensitive=False,
+ sample_format="https://mystore.example.com",
+ about_url="https://woocommerce.github.io/woocommerce-rest-api-docs/#authentication",
+ ),
+ EnvVar(
+ name="WOOCOMMERCE_CONSUMER_KEY",
+ display_name="Consumer Key",
+ description="WooCommerce REST API consumer key from WooCommerce > Settings > Advanced > REST API",
+ required=True,
+ sensitive=True,
+ sample_format="ck_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ about_url="https://woocommerce.github.io/woocommerce-rest-api-docs/#authentication",
+ ),
+ EnvVar(
+ name="WOOCOMMERCE_CONSUMER_SECRET",
+ display_name="Consumer Secret",
+ description="WooCommerce REST API consumer secret from WooCommerce > Settings > Advanced > REST API",
+ required=True,
+ sensitive=True,
+ sample_format="cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ about_url="https://woocommerce.github.io/woocommerce-rest-api-docs/#authentication",
+ ),
+ ],
+ ),
+ ],
+)
diff --git a/src/modulex_integrations/tools/woocommerce/outputs.py b/src/modulex_integrations/tools/woocommerce/outputs.py
new file mode 100644
index 0000000..da8b4f6
--- /dev/null
+++ b/src/modulex_integrations/tools/woocommerce/outputs.py
@@ -0,0 +1,216 @@
+"""Pydantic response models for the woocommerce integration's @tool functions."""
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field
+
+__all__ = [
+ "AddOrderNoteOutput",
+ "CreateCustomerOutput",
+ "CreateOrderOutput",
+ "CreateProductOutput",
+ "CreateRefundOutput",
+ "CustomerSummary",
+ "DeleteOrderOutput",
+ "GetCustomerOutput",
+ "GetOrderNoteOutput",
+ "GetOrderOutput",
+ "GetProductOutput",
+ "ListOrderNotesOutput",
+ "ListOrdersOutput",
+ "ListPaymentMethodOptionsOutput",
+ "ListProductsOutput",
+ "OrderNoteSummary",
+ "OrderSummary",
+ "PaymentMethodSummary",
+ "ProductSummary",
+ "RefundSummary",
+ "SearchCustomersOutput",
+ "UpdateOrderStatusOutput",
+ "UpdateProductOutput",
+]
+
+
+class _Base(BaseModel):
+ """Shared config for every output model in this integration."""
+
+ model_config = ConfigDict(extra="forbid")
+
+
+# --- Nested resource models -----------------------------------------------
+
+
+class OrderSummary(_Base):
+ id: int | None = None
+ number: str | None = None
+ status: str | None = None
+ total: str | None = None
+ currency: str | None = None
+ customer_id: int | None = None
+ payment_method: str | None = None
+ date_created: str | None = None
+ date_modified: str | None = None
+ line_items: list[dict[str, Any]] = Field(default_factory=list)
+ billing: dict[str, Any] | None = None
+ shipping: dict[str, Any] | None = None
+
+
+class ProductSummary(_Base):
+ id: int | None = None
+ name: str | None = None
+ slug: str | None = None
+ type: str | None = None
+ status: str | None = None
+ regular_price: str | None = None
+ sale_price: str | None = None
+ price: str | None = None
+ description: str | None = None
+ categories: list[dict[str, Any]] = Field(default_factory=list)
+ images: list[dict[str, Any]] = Field(default_factory=list)
+ date_created: str | None = None
+
+
+class CustomerSummary(_Base):
+ id: int | None = None
+ email: str | None = None
+ first_name: str | None = None
+ last_name: str | None = None
+ username: str | None = None
+ role: str | None = None
+ date_created: str | None = None
+ billing: dict[str, Any] | None = None
+ shipping: dict[str, Any] | None = None
+ is_paying_customer: bool | None = None
+
+
+class OrderNoteSummary(_Base):
+ id: int | None = None
+ author: str | None = None
+ date_created: str | None = None
+ note: str | None = None
+ customer_note: bool | None = None
+
+
+class RefundSummary(_Base):
+ id: int | None = None
+ amount: str | None = None
+ reason: str | None = None
+ refunded_by: int | None = None
+ date_created: str | None = None
+ line_items: list[dict[str, Any]] = Field(default_factory=list)
+
+
+class PaymentMethodSummary(_Base):
+ id: str | None = None
+ title: str | None = None
+ description: str | None = None
+ enabled: bool | None = None
+
+
+# --- Per-action output models ---------------------------------------------
+
+
+class CreateOrderOutput(_Base):
+ success: bool
+ error: str | None = None
+ order: OrderSummary | None = None
+
+
+class GetOrderOutput(_Base):
+ success: bool
+ error: str | None = None
+ order: OrderSummary | None = None
+
+
+class ListOrdersOutput(_Base):
+ success: bool
+ error: str | None = None
+ orders: list[OrderSummary] = Field(default_factory=list)
+ total: int = 0
+
+
+class DeleteOrderOutput(_Base):
+ success: bool
+ error: str | None = None
+ order: OrderSummary | None = None
+
+
+class UpdateOrderStatusOutput(_Base):
+ success: bool
+ error: str | None = None
+ order: OrderSummary | None = None
+
+
+class CreateProductOutput(_Base):
+ success: bool
+ error: str | None = None
+ product: ProductSummary | None = None
+
+
+class UpdateProductOutput(_Base):
+ success: bool
+ error: str | None = None
+ product: ProductSummary | None = None
+
+
+class GetProductOutput(_Base):
+ success: bool
+ error: str | None = None
+ product: ProductSummary | None = None
+
+
+class ListProductsOutput(_Base):
+ success: bool
+ error: str | None = None
+ products: list[ProductSummary] = Field(default_factory=list)
+ total: int = 0
+
+
+class SearchCustomersOutput(_Base):
+ success: bool
+ error: str | None = None
+ customers: list[CustomerSummary] = Field(default_factory=list)
+ total: int = 0
+
+
+class GetCustomerOutput(_Base):
+ success: bool
+ error: str | None = None
+ customer: CustomerSummary | None = None
+
+
+class CreateCustomerOutput(_Base):
+ success: bool
+ error: str | None = None
+ customer: CustomerSummary | None = None
+
+
+class AddOrderNoteOutput(_Base):
+ success: bool
+ error: str | None = None
+ note: OrderNoteSummary | None = None
+
+
+class GetOrderNoteOutput(_Base):
+ success: bool
+ error: str | None = None
+ note: OrderNoteSummary | None = None
+
+
+class ListOrderNotesOutput(_Base):
+ success: bool
+ error: str | None = None
+ notes: list[OrderNoteSummary] = Field(default_factory=list)
+
+
+class CreateRefundOutput(_Base):
+ success: bool
+ error: str | None = None
+ refund: RefundSummary | None = None
+
+
+class ListPaymentMethodOptionsOutput(_Base):
+ success: bool
+ error: str | None = None
+ payment_methods: list[PaymentMethodSummary] = Field(default_factory=list)
diff --git a/src/modulex_integrations/tools/woocommerce/tests/__init__.py b/src/modulex_integrations/tools/woocommerce/tests/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/modulex_integrations/tools/woocommerce/tests/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/modulex_integrations/tools/woocommerce/tests/test_woocommerce.py b/src/modulex_integrations/tools/woocommerce/tests/test_woocommerce.py
new file mode 100644
index 0000000..03b9948
--- /dev/null
+++ b/src/modulex_integrations/tools/woocommerce/tests/test_woocommerce.py
@@ -0,0 +1,466 @@
+"""Happy-path tests for every woocommerce @tool, plus a manifest sanity check."""
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from modulex_integrations.tools.woocommerce import (
+ TOOLS,
+ add_order_note,
+ create_customer,
+ create_order,
+ create_product,
+ create_refund,
+ delete_order,
+ get_customer,
+ get_order,
+ get_order_note,
+ get_product,
+ list_order_notes,
+ list_orders,
+ list_payment_method_options,
+ list_products,
+ manifest,
+ search_customers,
+ update_order_status,
+ update_product,
+)
+from modulex_integrations.tools.woocommerce.outputs import (
+ AddOrderNoteOutput,
+ CreateCustomerOutput,
+ CreateOrderOutput,
+ CreateProductOutput,
+ CreateRefundOutput,
+ DeleteOrderOutput,
+ GetCustomerOutput,
+ GetOrderNoteOutput,
+ GetOrderOutput,
+ GetProductOutput,
+ ListOrderNotesOutput,
+ ListOrdersOutput,
+ ListPaymentMethodOptionsOutput,
+ ListProductsOutput,
+ SearchCustomersOutput,
+ UpdateOrderStatusOutput,
+ UpdateProductOutput,
+)
+
+API = "https://mystore.example.com/wp-json/wc/v3"
+
+_AUTH: dict[str, Any] = {
+ "auth_type": "custom",
+ "auth_data": {
+ "store_url": "https://mystore.example.com",
+ "consumer_key": "ck_fake_key",
+ "consumer_secret": "cs_fake_secret",
+ },
+}
+
+
+def _args(**extra: Any) -> dict[str, Any]:
+ return dict(_AUTH, **extra)
+
+
+# --- Manifest sanity --------------------------------------------------------
+
+
+class TestManifest:
+ def test_manifest_exposes_17_actions(self) -> None:
+ assert len(manifest.actions) == 17
+
+ def test_manifest_actions_match_tools_tuple(self) -> None:
+ assert {a.name for a in manifest.actions} == {t.name for t in TOOLS}
+
+ def test_manifest_has_custom_auth(self) -> None:
+ assert {a.auth_type for a in manifest.auth_schemas} == {"custom"}
+
+
+# --- Per-action happy-path tests -------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_create_order(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/orders",
+ json={
+ # TODO: fill in a representative response shape from the WooCommerce API docs
+ "id": 123,
+ "number": "123",
+ "status": "pending",
+ "total": "50.00",
+ "currency": "USD",
+ "customer_id": 1,
+ "payment_method": "bacs",
+ "date_created": "2024-01-01T00:00:00",
+ "date_modified": "2024-01-01T00:00:00",
+ "line_items": [],
+ "billing": {},
+ "shipping": {},
+ },
+ status_code=201,
+ )
+
+ result_dict = await create_order.ainvoke(_args(line_items=[{"product_id": 1, "quantity": 2}]))
+
+ assert isinstance(result_dict, dict)
+ result = CreateOrderOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.order is not None
+ assert result.order.id == 123
+
+
+@pytest.mark.asyncio
+async def test_get_order(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/orders/123",
+ json={
+ "id": 123,
+ "number": "123",
+ "status": "processing",
+ "total": "99.00",
+ "currency": "USD",
+ "customer_id": 2,
+ "payment_method": "paypal",
+ "date_created": "2024-01-01T00:00:00",
+ "date_modified": "2024-01-02T00:00:00",
+ "line_items": [],
+ "billing": {},
+ "shipping": {},
+ },
+ )
+
+ result_dict = await get_order.ainvoke(_args(order_id=123))
+
+ assert isinstance(result_dict, dict)
+ result = GetOrderOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.order is not None
+ assert result.order.status == "processing"
+
+
+@pytest.mark.asyncio
+async def test_list_orders(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/orders?per_page=20&status=pending",
+ json=[
+ {"id": 1, "number": "1", "status": "pending", "total": "10.00", "currency": "USD", "customer_id": 0, "payment_method": "", "date_created": "2024-01-01T00:00:00", "date_modified": "2024-01-01T00:00:00", "line_items": [], "billing": {}, "shipping": {}},
+ ],
+ )
+
+ result_dict = await list_orders.ainvoke(_args())
+
+ assert isinstance(result_dict, dict)
+ result = ListOrdersOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.total == 1
+
+
+@pytest.mark.asyncio
+async def test_delete_order(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="DELETE",
+ url=f"{API}/orders/123",
+ json={"id": 123, "number": "123", "status": "trash", "total": "0.00", "currency": "USD", "customer_id": 0, "payment_method": "", "date_created": "2024-01-01T00:00:00", "date_modified": "2024-01-01T00:00:00", "line_items": [], "billing": {}, "shipping": {}},
+ )
+
+ result_dict = await delete_order.ainvoke(_args(order_id=123))
+
+ assert isinstance(result_dict, dict)
+ result = DeleteOrderOutput.model_validate(result_dict)
+ assert result.success is True
+
+
+@pytest.mark.asyncio
+async def test_update_order_status(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="PUT",
+ url=f"{API}/orders/123",
+ json={"id": 123, "number": "123", "status": "completed", "total": "50.00", "currency": "USD", "customer_id": 1, "payment_method": "bacs", "date_created": "2024-01-01T00:00:00", "date_modified": "2024-01-02T00:00:00", "line_items": [], "billing": {}, "shipping": {}},
+ )
+
+ result_dict = await update_order_status.ainvoke(_args(order_id=123, status="completed"))
+
+ assert isinstance(result_dict, dict)
+ result = UpdateOrderStatusOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.order is not None
+ assert result.order.status == "completed"
+
+
+@pytest.mark.asyncio
+async def test_create_product(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/products",
+ json={
+ "id": 456,
+ "name": "Test Product",
+ "slug": "test-product",
+ "type": "simple",
+ "status": "publish",
+ "regular_price": "29.99",
+ "sale_price": "",
+ "price": "29.99",
+ "description": "A test product",
+ "categories": [],
+ "images": [],
+ "date_created": "2024-01-01T00:00:00",
+ },
+ status_code=201,
+ )
+
+ result_dict = await create_product.ainvoke(_args(name="Test Product", regular_price="29.99"))
+
+ assert isinstance(result_dict, dict)
+ result = CreateProductOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.product is not None
+ assert result.product.name == "Test Product"
+
+
+@pytest.mark.asyncio
+async def test_update_product(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="PUT",
+ url=f"{API}/products/456",
+ json={
+ "id": 456,
+ "name": "Updated Product",
+ "slug": "updated-product",
+ "type": "simple",
+ "status": "publish",
+ "regular_price": "39.99",
+ "sale_price": "",
+ "price": "39.99",
+ "description": "",
+ "categories": [],
+ "images": [],
+ "date_created": "2024-01-01T00:00:00",
+ },
+ )
+
+ result_dict = await update_product.ainvoke(_args(product_id=456, name="Updated Product", regular_price="39.99"))
+
+ assert isinstance(result_dict, dict)
+ result = UpdateProductOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.product is not None
+ assert result.product.regular_price == "39.99"
+
+
+@pytest.mark.asyncio
+async def test_get_product(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/products/456",
+ json={
+ "id": 456,
+ "name": "Test Product",
+ "slug": "test-product",
+ "type": "simple",
+ "status": "publish",
+ "regular_price": "29.99",
+ "sale_price": "",
+ "price": "29.99",
+ "description": "",
+ "categories": [],
+ "images": [],
+ "date_created": "2024-01-01T00:00:00",
+ },
+ )
+
+ result_dict = await get_product.ainvoke(_args(product_id=456))
+
+ assert isinstance(result_dict, dict)
+ result = GetProductOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.product is not None
+ assert result.product.id == 456
+
+
+@pytest.mark.asyncio
+async def test_list_products(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/products?per_page=20&status=publish&type=simple",
+ json=[
+ {"id": 1, "name": "P1", "slug": "p1", "type": "simple", "status": "publish", "regular_price": "10.00", "sale_price": "", "price": "10.00", "description": "", "categories": [], "images": [], "date_created": "2024-01-01T00:00:00"},
+ ],
+ )
+
+ result_dict = await list_products.ainvoke(_args())
+
+ assert isinstance(result_dict, dict)
+ result = ListProductsOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.total == 1
+
+
+@pytest.mark.asyncio
+async def test_search_customers(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/customers?per_page=20&role=customer",
+ json=[
+ {"id": 10, "email": "test@example.com", "first_name": "Test", "last_name": "User", "username": "testuser", "role": "customer", "date_created": "2024-01-01T00:00:00", "billing": {}, "shipping": {}, "is_paying_customer": True},
+ ],
+ )
+
+ result_dict = await search_customers.ainvoke(_args())
+
+ assert isinstance(result_dict, dict)
+ result = SearchCustomersOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.total == 1
+
+
+@pytest.mark.asyncio
+async def test_get_customer(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/customers/10",
+ json={"id": 10, "email": "test@example.com", "first_name": "Test", "last_name": "User", "username": "testuser", "role": "customer", "date_created": "2024-01-01T00:00:00", "billing": {}, "shipping": {}, "is_paying_customer": True},
+ )
+
+ result_dict = await get_customer.ainvoke(_args(customer_id=10))
+
+ assert isinstance(result_dict, dict)
+ result = GetCustomerOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.customer is not None
+ assert result.customer.email == "test@example.com"
+
+
+@pytest.mark.asyncio
+async def test_create_customer(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/customers",
+ json={"id": 11, "email": "new@example.com", "first_name": "New", "last_name": "Customer", "username": "newcustomer", "role": "customer", "date_created": "2024-01-01T00:00:00", "billing": {}, "shipping": {}, "is_paying_customer": False},
+ status_code=201,
+ )
+
+ result_dict = await create_customer.ainvoke(_args(email="new@example.com", first_name="New", last_name="Customer"))
+
+ assert isinstance(result_dict, dict)
+ result = CreateCustomerOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.customer is not None
+ assert result.customer.id == 11
+
+
+@pytest.mark.asyncio
+async def test_add_order_note(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/orders/123/notes",
+ json={"id": 1, "author": "system", "date_created": "2024-01-01T00:00:00", "note": "Test note", "customer_note": False},
+ status_code=201,
+ )
+
+ result_dict = await add_order_note.ainvoke(_args(order_id=123, note="Test note"))
+
+ assert isinstance(result_dict, dict)
+ result = AddOrderNoteOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.note is not None
+ assert result.note.note == "Test note"
+
+
+@pytest.mark.asyncio
+async def test_get_order_note(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/orders/123/notes/1",
+ json={"id": 1, "author": "system", "date_created": "2024-01-01T00:00:00", "note": "A note", "customer_note": False},
+ )
+
+ result_dict = await get_order_note.ainvoke(_args(order_id=123, note_id=1))
+
+ assert isinstance(result_dict, dict)
+ result = GetOrderNoteOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.note is not None
+
+
+@pytest.mark.asyncio
+async def test_list_order_notes(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/orders/123/notes",
+ json=[
+ {"id": 1, "author": "system", "date_created": "2024-01-01T00:00:00", "note": "Note 1", "customer_note": False},
+ {"id": 2, "author": "admin", "date_created": "2024-01-02T00:00:00", "note": "Note 2", "customer_note": True},
+ ],
+ )
+
+ result_dict = await list_order_notes.ainvoke(_args(order_id=123))
+
+ assert isinstance(result_dict, dict)
+ result = ListOrderNotesOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.notes) == 2
+
+
+@pytest.mark.asyncio
+async def test_create_refund(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="POST",
+ url=f"{API}/orders/123/refunds",
+ json={"id": 5, "amount": "25.00", "reason": "Damaged item", "refunded_by": 1, "date_created": "2024-01-01T00:00:00", "line_items": []},
+ status_code=201,
+ )
+
+ result_dict = await create_refund.ainvoke(_args(order_id=123, amount="25.00", reason="Damaged item"))
+
+ assert isinstance(result_dict, dict)
+ result = CreateRefundOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.refund is not None
+ assert result.refund.amount == "25.00"
+
+
+@pytest.mark.asyncio
+async def test_list_payment_method_options(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/payment_gateways",
+ json=[
+ {"id": "bacs", "title": "Direct bank transfer", "description": "Make your payment directly.", "enabled": True},
+ {"id": "cod", "title": "Cash on delivery", "description": "Pay with cash.", "enabled": True},
+ ],
+ )
+
+ result_dict = await list_payment_method_options.ainvoke(_args())
+
+ assert isinstance(result_dict, dict)
+ result = ListPaymentMethodOptionsOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.payment_methods) == 2
+
+
+# --- Failure-path tests ----------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_create_order_empty_credentials() -> None:
+ """Credential validation rejects empty store_url / consumer_key / consumer_secret."""
+ result_dict = await create_order.ainvoke(
+ {
+ "auth_type": "custom",
+ "auth_data": {
+ "store_url": "",
+ "consumer_key": "",
+ "consumer_secret": "",
+ },
+ }
+ )
+
+ assert isinstance(result_dict, dict)
+ result = CreateOrderOutput.model_validate(result_dict)
+ assert result.success is False
+ assert result.error is not None
diff --git a/src/modulex_integrations/tools/woocommerce/tools.py b/src/modulex_integrations/tools/woocommerce/tools.py
new file mode 100644
index 0000000..5bd6dbe
--- /dev/null
+++ b/src/modulex_integrations/tools/woocommerce/tools.py
@@ -0,0 +1,961 @@
+"""WooCommerce LangChain @tool functions."""
+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.woocommerce.outputs import (
+ AddOrderNoteOutput,
+ CreateCustomerOutput,
+ CreateOrderOutput,
+ CreateProductOutput,
+ CreateRefundOutput,
+ CustomerSummary,
+ DeleteOrderOutput,
+ GetCustomerOutput,
+ GetOrderNoteOutput,
+ GetOrderOutput,
+ GetProductOutput,
+ ListOrderNotesOutput,
+ ListOrdersOutput,
+ ListPaymentMethodOptionsOutput,
+ ListProductsOutput,
+ OrderNoteSummary,
+ OrderSummary,
+ PaymentMethodSummary,
+ ProductSummary,
+ RefundSummary,
+ SearchCustomersOutput,
+ UpdateOrderStatusOutput,
+ UpdateProductOutput,
+)
+
+__all__ = [
+ "add_order_note",
+ "create_customer",
+ "create_order",
+ "create_product",
+ "create_refund",
+ "delete_order",
+ "get_customer",
+ "get_order",
+ "get_order_note",
+ "get_product",
+ "list_order_notes",
+ "list_orders",
+ "list_payment_method_options",
+ "list_products",
+ "search_customers",
+ "update_order_status",
+ "update_product",
+]
+
+_TIMEOUT = 30.0
+
+
+def _build_base_url(auth_data: dict[str, Any]) -> str:
+ store_url = auth_data.get("store_url", "").rstrip("/")
+ return f"{store_url}/wp-json/wc/v3"
+
+
+def _get_auth(auth_data: dict[str, Any]) -> tuple[str, str]:
+ return (
+ auth_data.get("consumer_key", ""),
+ auth_data.get("consumer_secret", ""),
+ )
+
+
+def _validate_credentials(auth_data: dict[str, Any]) -> str | None:
+ store_url = auth_data.get("store_url", "")
+ consumer_key = auth_data.get("consumer_key", "")
+ consumer_secret = auth_data.get("consumer_secret", "")
+ if not store_url or not store_url.strip():
+ return "Store URL is empty. Please configure a valid WooCommerce store URL."
+ if not consumer_key or not consumer_key.strip():
+ return "Consumer key is empty. Please configure valid WooCommerce REST API credentials."
+ if not consumer_secret or not consumer_secret.strip():
+ return "Consumer secret is empty. Please configure valid WooCommerce REST API credentials."
+ return None
+
+
+def _parse_order(data: dict[str, Any]) -> OrderSummary:
+ return OrderSummary(
+ id=data.get("id"),
+ number=str(data.get("number", "")),
+ status=data.get("status"),
+ total=data.get("total"),
+ currency=data.get("currency"),
+ customer_id=data.get("customer_id"),
+ payment_method=data.get("payment_method"),
+ date_created=data.get("date_created"),
+ date_modified=data.get("date_modified"),
+ line_items=data.get("line_items") or [],
+ billing=data.get("billing"),
+ shipping=data.get("shipping"),
+ )
+
+
+def _parse_product(data: dict[str, Any]) -> ProductSummary:
+ return ProductSummary(
+ id=data.get("id"),
+ name=data.get("name"),
+ slug=data.get("slug"),
+ type=data.get("type"),
+ status=data.get("status"),
+ regular_price=data.get("regular_price"),
+ sale_price=data.get("sale_price"),
+ price=data.get("price"),
+ description=data.get("description"),
+ categories=data.get("categories") or [],
+ images=data.get("images") or [],
+ date_created=data.get("date_created"),
+ )
+
+
+def _parse_customer(data: dict[str, Any]) -> CustomerSummary:
+ return CustomerSummary(
+ id=data.get("id"),
+ email=data.get("email"),
+ first_name=data.get("first_name"),
+ last_name=data.get("last_name"),
+ username=data.get("username"),
+ role=data.get("role"),
+ date_created=data.get("date_created"),
+ billing=data.get("billing"),
+ shipping=data.get("shipping"),
+ is_paying_customer=data.get("is_paying_customer"),
+ )
+
+
+def _parse_order_note(data: dict[str, Any]) -> OrderNoteSummary:
+ return OrderNoteSummary(
+ id=data.get("id"),
+ author=data.get("author"),
+ date_created=data.get("date_created"),
+ note=data.get("note"),
+ customer_note=data.get("customer_note"),
+ )
+
+
+# --- Input schemas --------------------------------------------------------
+
+
+class CreateOrderInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ status: str | None = Field(default="pending", description="Order status. Options: pending, processing, on-hold, completed, cancelled, refunded, failed, trash")
+ customer_id: int | None = Field(default=None, description="User ID who owns the order. 0 for guests")
+ payment_method: str | None = Field(default=None, description="Payment method ID (e.g. bacs, cheque, cod, paypal)")
+ line_items: list[dict[str, Any]] | None = Field(default=None, description="Array of line item objects, each with product_id (integer) and quantity (integer)")
+
+
+class GetOrderInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ order_id: int = Field(description="ID of the order to retrieve")
+
+
+class ListOrdersInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ search: str | None = Field(default=None, description="Limit results to those matching a string")
+ status: str | None = Field(default="pending", description="Order status filter. Options: pending, processing, on-hold, completed, cancelled, refunded, failed, trash")
+ customer: int | None = Field(default=None, description="Filter by customer user ID. 0 for guests")
+ after: str | None = Field(default=None, description="Limit to orders created after this ISO8601 date")
+ before: str | None = Field(default=None, description="Limit to orders created before this ISO8601 date")
+ max_results: int = Field(default=20, description="Maximum number of results to return")
+
+
+class DeleteOrderInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ order_id: int = Field(description="ID of the order to delete")
+ force: bool | None = Field(default=None, description="Whether to bypass trash and permanently delete the order")
+
+
+class UpdateOrderStatusInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ order_id: int = Field(description="ID of the order to update")
+ status: str = Field(description="New order status. Options: pending, processing, on-hold, completed, cancelled, refunded, failed, trash")
+
+
+class CreateProductInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ name: str = Field(description="Name of the product")
+ type: str | None = Field(default="simple", description="Product type. Options: simple, grouped, external, variable")
+ status: str | None = Field(default="publish", description="Product status. Options: draft, pending, private, publish")
+ regular_price: str | None = Field(default=None, description="Product regular price")
+ sale_price: str | None = Field(default=None, description="Product sale price")
+ description: str | None = Field(default=None, description="Product description (HTML allowed)")
+ categories: list[int] | None = Field(default=None, description="Array of category IDs to assign the product to")
+ image_url: str | None = Field(default=None, description="URL of an image to add to the product")
+
+
+class UpdateProductInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ product_id: int = Field(description="ID of the product to update")
+ name: str | None = Field(default=None, description="New name for the product")
+ type: str | None = Field(default=None, description="Product type. Options: simple, grouped, external, variable")
+ status: str | None = Field(default=None, description="Product status. Options: draft, pending, private, publish")
+ regular_price: str | None = Field(default=None, description="Product regular price")
+ sale_price: str | None = Field(default=None, description="Product sale price")
+ description: str | None = Field(default=None, description="Product description (HTML allowed)")
+ categories: list[int] | None = Field(default=None, description="Array of category IDs to assign the product to")
+ image_url: str | None = Field(default=None, description="URL of an image to add to the product")
+
+
+class GetProductInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ product_id: int = Field(description="ID of the product to retrieve")
+
+
+class ListProductsInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ search: str | None = Field(default=None, description="Limit results to those matching a string")
+ status: str | None = Field(default="publish", description="Product status filter. Options: draft, pending, private, publish")
+ type: str | None = Field(default="simple", description="Product type filter. Options: simple, grouped, external, variable")
+ after: str | None = Field(default=None, description="Limit to products created after this ISO8601 date")
+ before: str | None = Field(default=None, description="Limit to products created before this ISO8601 date")
+ max_results: int = Field(default=20, description="Maximum number of results to return")
+
+
+class SearchCustomersInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ search: str | None = Field(default=None, description="Limit results to those matching a string")
+ email: str | None = Field(default=None, description="Filter by exact customer email address")
+ role: str | None = Field(default="customer", description="Filter by role. Options: all, administrator, editor, author, contributor, subscriber, customer")
+ max_results: int = Field(default=20, description="Maximum number of results to return")
+
+
+class GetCustomerInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ customer_id: int = Field(description="ID of the customer to retrieve")
+
+
+class CreateCustomerInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ email: str = Field(description="Customer email address")
+ first_name: str | None = Field(default=None, description="Customer first name")
+ last_name: str | None = Field(default=None, description="Customer last name")
+ username: str | None = Field(default=None, description="Customer login username")
+ password: str | None = Field(default=None, description="Customer password")
+ is_paying_customer: bool | None = Field(default=None, description="Whether the customer is a paying customer")
+
+
+class AddOrderNoteInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ order_id: int = Field(description="ID of the order to add a note to")
+ note: str = Field(description="Content of the order note")
+
+
+class GetOrderNoteInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ order_id: int = Field(description="ID of the order")
+ note_id: int = Field(description="ID of the order note")
+
+
+class ListOrderNotesInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ order_id: int = Field(description="ID of the order")
+ type: str | None = Field(default="any", description="Filter by note type. Options: any, customer, internal")
+
+
+class CreateRefundInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+ order_id: int = Field(description="ID of the order to refund")
+ amount: str | None = Field(default=None, description="Refund amount. If not specified, calculated from line items")
+ reason: str | None = Field(default=None, description="Reason for the refund")
+ api_refund: bool | None = Field(default=None, description="When true, the payment gateway API generates the refund")
+ line_items: list[dict[str, Any]] | None = Field(default=None, description="Array of line item refund objects with id, refund_total, and optionally refund_tax")
+
+
+class ListPaymentMethodOptionsInput(BaseModel):
+ auth_type: str = Field(description="Authentication type")
+ auth_data: dict[str, Any] = Field(description="Authentication data")
+
+
+# --- @tool functions ------------------------------------------------------
+
+
+@tool(args_schema=CreateOrderInput)
+@serialize_pydantic_return
+async def create_order(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ status: str | None = "pending",
+ customer_id: int | None = None,
+ payment_method: str | None = None,
+ line_items: list[dict[str, Any]] | None = None,
+) -> CreateOrderOutput:
+ """Create a new order in the WooCommerce store."""
+ err = _validate_credentials(auth_data)
+ if err:
+ return CreateOrderOutput(success=False, error=err)
+ base_url = _build_base_url(auth_data)
+ auth = _get_auth(auth_data)
+ body: dict[str, Any] = {}
+ if status:
+ body["status"] = status
+ if customer_id is not None:
+ body["customer_id"] = customer_id
+ if payment_method:
+ body["payment_method"] = payment_method
+ if line_items:
+ body["line_items"] = line_items
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.post(
+ f"{base_url}/orders",
+ auth=auth,
+ json=body,
+ )
+ if response.status_code not in (200, 201):
+ return CreateOrderOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return CreateOrderOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return CreateOrderOutput(success=False, error=f"Call failed: {exc}")
+ return CreateOrderOutput(success=True, order=_parse_order(data))
+
+
+@tool(args_schema=GetOrderInput)
+@serialize_pydantic_return
+async def get_order(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ order_id: int,
+) -> GetOrderOutput:
+ """Retrieve a specific order by ID."""
+ err = _validate_credentials(auth_data)
+ if err:
+ return GetOrderOutput(success=False, error=err)
+ base_url = _build_base_url(auth_data)
+ auth = _get_auth(auth_data)
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{base_url}/orders/{order_id}",
+ auth=auth,
+ )
+ if response.status_code != 200:
+ return GetOrderOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return GetOrderOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return GetOrderOutput(success=False, error=f"Call failed: {exc}")
+ return GetOrderOutput(success=True, order=_parse_order(data))
+
+
+@tool(args_schema=ListOrdersInput)
+@serialize_pydantic_return
+async def list_orders(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ search: str | None = None,
+ status: str | None = "pending",
+ customer: int | None = None,
+ after: str | None = None,
+ before: str | None = None,
+ max_results: int = 20,
+) -> ListOrdersOutput:
+ """Retrieve a list of orders with optional filters."""
+ err = _validate_credentials(auth_data)
+ if err:
+ return ListOrdersOutput(success=False, error=err)
+ base_url = _build_base_url(auth_data)
+ auth = _get_auth(auth_data)
+ params: dict[str, Any] = {"per_page": min(max_results, 100)}
+ if search:
+ params["search"] = search
+ if status:
+ params["status"] = status
+ if customer is not None:
+ params["customer"] = customer
+ if after:
+ params["after"] = after
+ if before:
+ params["before"] = before
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{base_url}/orders",
+ auth=auth,
+ params=params,
+ )
+ if response.status_code != 200:
+ return ListOrdersOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return ListOrdersOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return ListOrdersOutput(success=False, error=f"Call failed: {exc}")
+ orders = [_parse_order(o) for o in data]
+ return ListOrdersOutput(success=True, orders=orders, total=len(orders))
+
+
+@tool(args_schema=DeleteOrderInput)
+@serialize_pydantic_return
+async def delete_order(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ order_id: int,
+ force: bool | None = None,
+) -> DeleteOrderOutput:
+ """Delete an existing order."""
+ err = _validate_credentials(auth_data)
+ if err:
+ return DeleteOrderOutput(success=False, error=err)
+ base_url = _build_base_url(auth_data)
+ auth = _get_auth(auth_data)
+ params: dict[str, Any] = {}
+ if force is not None:
+ params["force"] = str(force).lower()
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.delete(
+ f"{base_url}/orders/{order_id}",
+ auth=auth,
+ params=params,
+ )
+ if response.status_code != 200:
+ return DeleteOrderOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return DeleteOrderOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return DeleteOrderOutput(success=False, error=f"Call failed: {exc}")
+ return DeleteOrderOutput(success=True, order=_parse_order(data))
+
+
+@tool(args_schema=UpdateOrderStatusInput)
+@serialize_pydantic_return
+async def update_order_status(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ order_id: int,
+ status: str,
+) -> UpdateOrderStatusOutput:
+ """Update the status of a specific order."""
+ err = _validate_credentials(auth_data)
+ if err:
+ return UpdateOrderStatusOutput(success=False, error=err)
+ base_url = _build_base_url(auth_data)
+ auth = _get_auth(auth_data)
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.put(
+ f"{base_url}/orders/{order_id}",
+ auth=auth,
+ json={"status": status},
+ )
+ if response.status_code != 200:
+ return UpdateOrderStatusOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return UpdateOrderStatusOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return UpdateOrderStatusOutput(success=False, error=f"Call failed: {exc}")
+ return UpdateOrderStatusOutput(success=True, order=_parse_order(data))
+
+
+@tool(args_schema=CreateProductInput)
+@serialize_pydantic_return
+async def create_product(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ name: str,
+ type: str | None = "simple",
+ status: str | None = "publish",
+ regular_price: str | None = None,
+ sale_price: str | None = None,
+ description: str | None = None,
+ categories: list[int] | None = None,
+ image_url: str | None = None,
+) -> CreateProductOutput:
+ """Create a new product in the WooCommerce store."""
+ err = _validate_credentials(auth_data)
+ if err:
+ return CreateProductOutput(success=False, error=err)
+ base_url = _build_base_url(auth_data)
+ auth = _get_auth(auth_data)
+ body: dict[str, Any] = {"name": name}
+ if type:
+ body["type"] = type
+ if status:
+ body["status"] = status
+ if regular_price:
+ body["regular_price"] = regular_price
+ if sale_price:
+ body["sale_price"] = sale_price
+ if description:
+ body["description"] = description
+ if categories:
+ body["categories"] = [{"id": c} for c in categories]
+ if image_url:
+ body["images"] = [{"src": image_url}]
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.post(
+ f"{base_url}/products",
+ auth=auth,
+ json=body,
+ )
+ if response.status_code not in (200, 201):
+ return CreateProductOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return CreateProductOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return CreateProductOutput(success=False, error=f"Call failed: {exc}")
+ return CreateProductOutput(success=True, product=_parse_product(data))
+
+
+@tool(args_schema=UpdateProductInput)
+@serialize_pydantic_return
+async def update_product(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ product_id: int,
+ name: str | None = None,
+ type: str | None = None,
+ status: str | None = None,
+ regular_price: str | None = None,
+ sale_price: str | None = None,
+ description: str | None = None,
+ categories: list[int] | None = None,
+ image_url: str | None = None,
+) -> UpdateProductOutput:
+ """Update an existing product."""
+ err = _validate_credentials(auth_data)
+ if err:
+ return UpdateProductOutput(success=False, error=err)
+ base_url = _build_base_url(auth_data)
+ auth = _get_auth(auth_data)
+ body: dict[str, Any] = {}
+ if name:
+ body["name"] = name
+ if type:
+ body["type"] = type
+ if status:
+ body["status"] = status
+ if regular_price:
+ body["regular_price"] = regular_price
+ if sale_price:
+ body["sale_price"] = sale_price
+ if description:
+ body["description"] = description
+ if categories:
+ body["categories"] = [{"id": c} for c in categories]
+ if image_url:
+ body["images"] = [{"src": image_url}]
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.put(
+ f"{base_url}/products/{product_id}",
+ auth=auth,
+ json=body,
+ )
+ if response.status_code != 200:
+ return UpdateProductOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return UpdateProductOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return UpdateProductOutput(success=False, error=f"Call failed: {exc}")
+ return UpdateProductOutput(success=True, product=_parse_product(data))
+
+
+@tool(args_schema=GetProductInput)
+@serialize_pydantic_return
+async def get_product(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ product_id: int,
+) -> GetProductOutput:
+ """Retrieve a specific product by ID."""
+ err = _validate_credentials(auth_data)
+ if err:
+ return GetProductOutput(success=False, error=err)
+ base_url = _build_base_url(auth_data)
+ auth = _get_auth(auth_data)
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{base_url}/products/{product_id}",
+ auth=auth,
+ )
+ if response.status_code != 200:
+ return GetProductOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return GetProductOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return GetProductOutput(success=False, error=f"Call failed: {exc}")
+ return GetProductOutput(success=True, product=_parse_product(data))
+
+
+@tool(args_schema=ListProductsInput)
+@serialize_pydantic_return
+async def list_products(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ search: str | None = None,
+ status: str | None = "publish",
+ type: str | None = "simple",
+ after: str | None = None,
+ before: str | None = None,
+ max_results: int = 20,
+) -> ListProductsOutput:
+ """Retrieve a list of products with optional filters."""
+ err = _validate_credentials(auth_data)
+ if err:
+ return ListProductsOutput(success=False, error=err)
+ base_url = _build_base_url(auth_data)
+ auth = _get_auth(auth_data)
+ params: dict[str, Any] = {"per_page": min(max_results, 100)}
+ if search:
+ params["search"] = search
+ if status:
+ params["status"] = status
+ if type:
+ params["type"] = type
+ if after:
+ params["after"] = after
+ if before:
+ params["before"] = before
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{base_url}/products",
+ auth=auth,
+ params=params,
+ )
+ if response.status_code != 200:
+ return ListProductsOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return ListProductsOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return ListProductsOutput(success=False, error=f"Call failed: {exc}")
+ products = [_parse_product(p) for p in data]
+ return ListProductsOutput(success=True, products=products, total=len(products))
+
+
+@tool(args_schema=SearchCustomersInput)
+@serialize_pydantic_return
+async def search_customers(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ search: str | None = None,
+ email: str | None = None,
+ role: str | None = "customer",
+ max_results: int = 20,
+) -> SearchCustomersOutput:
+ """Search for customers by email, name, or other criteria."""
+ err = _validate_credentials(auth_data)
+ if err:
+ return SearchCustomersOutput(success=False, error=err)
+ base_url = _build_base_url(auth_data)
+ auth = _get_auth(auth_data)
+ params: dict[str, Any] = {"per_page": min(max_results, 100)}
+ if search:
+ params["search"] = search
+ if email:
+ params["email"] = email
+ if role:
+ params["role"] = role
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{base_url}/customers",
+ auth=auth,
+ params=params,
+ )
+ if response.status_code != 200:
+ return SearchCustomersOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return SearchCustomersOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return SearchCustomersOutput(success=False, error=f"Call failed: {exc}")
+ customers = [_parse_customer(c) for c in data]
+ return SearchCustomersOutput(success=True, customers=customers, total=len(customers))
+
+
+@tool(args_schema=GetCustomerInput)
+@serialize_pydantic_return
+async def get_customer(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ customer_id: int,
+) -> GetCustomerOutput:
+ """Retrieve a specific customer by ID."""
+ err = _validate_credentials(auth_data)
+ if err:
+ return GetCustomerOutput(success=False, error=err)
+ base_url = _build_base_url(auth_data)
+ auth = _get_auth(auth_data)
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{base_url}/customers/{customer_id}",
+ auth=auth,
+ )
+ if response.status_code != 200:
+ return GetCustomerOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return GetCustomerOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return GetCustomerOutput(success=False, error=f"Call failed: {exc}")
+ return GetCustomerOutput(success=True, customer=_parse_customer(data))
+
+
+@tool(args_schema=CreateCustomerInput)
+@serialize_pydantic_return
+async def create_customer(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ email: str,
+ first_name: str | None = None,
+ last_name: str | None = None,
+ username: str | None = None,
+ password: str | None = None,
+ is_paying_customer: bool | None = None,
+) -> CreateCustomerOutput:
+ """Create a new customer."""
+ err = _validate_credentials(auth_data)
+ if err:
+ return CreateCustomerOutput(success=False, error=err)
+ base_url = _build_base_url(auth_data)
+ auth = _get_auth(auth_data)
+ body: dict[str, Any] = {"email": email}
+ if first_name:
+ body["first_name"] = first_name
+ if last_name:
+ body["last_name"] = last_name
+ if username:
+ body["username"] = username
+ if password:
+ body["password"] = password
+ if is_paying_customer is not None:
+ body["is_paying_customer"] = is_paying_customer
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.post(
+ f"{base_url}/customers",
+ auth=auth,
+ json=body,
+ )
+ if response.status_code not in (200, 201):
+ return CreateCustomerOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return CreateCustomerOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return CreateCustomerOutput(success=False, error=f"Call failed: {exc}")
+ return CreateCustomerOutput(success=True, customer=_parse_customer(data))
+
+
+@tool(args_schema=AddOrderNoteInput)
+@serialize_pydantic_return
+async def add_order_note(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ order_id: int,
+ note: str,
+) -> AddOrderNoteOutput:
+ """Create a new note for an order."""
+ err = _validate_credentials(auth_data)
+ if err:
+ return AddOrderNoteOutput(success=False, error=err)
+ base_url = _build_base_url(auth_data)
+ auth = _get_auth(auth_data)
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.post(
+ f"{base_url}/orders/{order_id}/notes",
+ auth=auth,
+ json={"note": note},
+ )
+ if response.status_code not in (200, 201):
+ return AddOrderNoteOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return AddOrderNoteOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return AddOrderNoteOutput(success=False, error=f"Call failed: {exc}")
+ return AddOrderNoteOutput(success=True, note=_parse_order_note(data))
+
+
+@tool(args_schema=GetOrderNoteInput)
+@serialize_pydantic_return
+async def get_order_note(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ order_id: int,
+ note_id: int,
+) -> GetOrderNoteOutput:
+ """Retrieve a specific order note."""
+ err = _validate_credentials(auth_data)
+ if err:
+ return GetOrderNoteOutput(success=False, error=err)
+ base_url = _build_base_url(auth_data)
+ auth = _get_auth(auth_data)
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{base_url}/orders/{order_id}/notes/{note_id}",
+ auth=auth,
+ )
+ if response.status_code != 200:
+ return GetOrderNoteOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return GetOrderNoteOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return GetOrderNoteOutput(success=False, error=f"Call failed: {exc}")
+ return GetOrderNoteOutput(success=True, note=_parse_order_note(data))
+
+
+@tool(args_schema=ListOrderNotesInput)
+@serialize_pydantic_return
+async def list_order_notes(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ order_id: int,
+ type: str | None = "any",
+) -> ListOrderNotesOutput:
+ """Retrieve all notes for a specific order."""
+ err = _validate_credentials(auth_data)
+ if err:
+ return ListOrderNotesOutput(success=False, error=err)
+ base_url = _build_base_url(auth_data)
+ auth = _get_auth(auth_data)
+ params: dict[str, Any] = {}
+ if type and type != "any":
+ params["type"] = type
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{base_url}/orders/{order_id}/notes",
+ auth=auth,
+ params=params,
+ )
+ if response.status_code != 200:
+ return ListOrderNotesOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return ListOrderNotesOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return ListOrderNotesOutput(success=False, error=f"Call failed: {exc}")
+ notes = [_parse_order_note(n) for n in data]
+ return ListOrderNotesOutput(success=True, notes=notes)
+
+
+@tool(args_schema=CreateRefundInput)
+@serialize_pydantic_return
+async def create_refund(
+ auth_type: str,
+ auth_data: dict[str, Any],
+ order_id: int,
+ amount: str | None = None,
+ reason: str | None = None,
+ api_refund: bool | None = None,
+ line_items: list[dict[str, Any]] | None = None,
+) -> CreateRefundOutput:
+ """Create a new refund for an order."""
+ err = _validate_credentials(auth_data)
+ if err:
+ return CreateRefundOutput(success=False, error=err)
+ base_url = _build_base_url(auth_data)
+ auth = _get_auth(auth_data)
+ body: dict[str, Any] = {}
+ if amount:
+ body["amount"] = amount
+ if reason:
+ body["reason"] = reason
+ if api_refund is not None:
+ body["api_refund"] = api_refund
+ if line_items:
+ body["line_items"] = line_items
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.post(
+ f"{base_url}/orders/{order_id}/refunds",
+ auth=auth,
+ json=body,
+ )
+ if response.status_code not in (200, 201):
+ return CreateRefundOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return CreateRefundOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return CreateRefundOutput(success=False, error=f"Call failed: {exc}")
+ return CreateRefundOutput(
+ success=True,
+ refund=RefundSummary(
+ id=data.get("id"),
+ amount=data.get("amount"),
+ reason=data.get("reason"),
+ refunded_by=data.get("refunded_by"),
+ date_created=data.get("date_created"),
+ line_items=data.get("line_items") or [],
+ ),
+ )
+
+
+@tool(args_schema=ListPaymentMethodOptionsInput)
+@serialize_pydantic_return
+async def list_payment_method_options(
+ auth_type: str,
+ auth_data: dict[str, Any],
+) -> ListPaymentMethodOptionsOutput:
+ """Retrieve available payment gateway options."""
+ err = _validate_credentials(auth_data)
+ if err:
+ return ListPaymentMethodOptionsOutput(success=False, error=err)
+ base_url = _build_base_url(auth_data)
+ auth = _get_auth(auth_data)
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{base_url}/payment_gateways",
+ auth=auth,
+ )
+ if response.status_code != 200:
+ return ListPaymentMethodOptionsOutput(success=False, error=f"API error ({response.status_code}): {response.text}")
+ data = response.json()
+ except httpx.TimeoutException:
+ return ListPaymentMethodOptionsOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return ListPaymentMethodOptionsOutput(success=False, error=f"Call failed: {exc}")
+ methods = [
+ PaymentMethodSummary(
+ id=m.get("id"),
+ title=m.get("title"),
+ description=m.get("description"),
+ enabled=m.get("enabled"),
+ )
+ for m in data
+ ]
+ return ListPaymentMethodOptionsOutput(success=True, payment_methods=methods)
diff --git a/src/modulex_integrations/tools/yelp/README.md b/src/modulex_integrations/tools/yelp/README.md
new file mode 100644
index 0000000..5c9684d
--- /dev/null
+++ b/src/modulex_integrations/tools/yelp/README.md
@@ -0,0 +1,34 @@
+# Yelp
+
+Search for businesses, read reviews, and get business details via the Yelp Fusion API (`api.yelp.com/v3`).
+
+## Authentication
+
+### API Key Authentication
+
+- Sign in at and navigate to "Manage App" or create a new app.
+- Copy your API Key from the app settings page.
+- Required env var: `YELP_API_KEY` (format: long alphanumeric string).
+- The API key is sent as a Bearer token in the Authorization header.
+
+## Tools
+
+| name | description | required params |
+| --- | --- | --- |
+| `search_businesses` | Search businesses matching given criteria such as location, term, categories, price, and attributes | (one of `location` or `latitude`+`longitude`) |
+| `get_business_details` | Get detailed information about a specific business by its Yelp ID or alias | `business_id_or_alias` |
+| `list_business_reviews` | List the reviews for a specific business | `business_id_or_alias` |
+| `search_businesses_by_phone_number` | Search for businesses by phone number | `phone` |
+
+Every tool takes an additional `api_key` parameter that the runtime fills in from the resolved credential.
+
+## Limits & Quotas
+
+- **Rate limit**: 5,000 API calls per day (per Yelp Fusion free tier).
+- **Search pagination**: Maximum offset of 1,000 results; each page returns up to 50 businesses.
+- **Reviews**: Returns up to 3 reviews per business (Yelp API limitation).
+- **Error model**: Non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising.
+
+## Maintainer
+
+ModuleX core team.
diff --git a/src/modulex_integrations/tools/yelp/__init__.py b/src/modulex_integrations/tools/yelp/__init__.py
new file mode 100644
index 0000000..687bccb
--- /dev/null
+++ b/src/modulex_integrations/tools/yelp/__init__.py
@@ -0,0 +1,24 @@
+"""Yelp integration — discovered via the ``modulex.tools`` entry point."""
+from modulex_integrations.tools.yelp.manifest import manifest
+from modulex_integrations.tools.yelp.tools import (
+ get_business_details,
+ list_business_reviews,
+ search_businesses,
+ search_businesses_by_phone_number,
+)
+
+TOOLS = (
+ search_businesses,
+ get_business_details,
+ list_business_reviews,
+ search_businesses_by_phone_number,
+)
+
+__all__ = [
+ "TOOLS",
+ "get_business_details",
+ "list_business_reviews",
+ "manifest",
+ "search_businesses",
+ "search_businesses_by_phone_number",
+]
diff --git a/src/modulex_integrations/tools/yelp/dependencies.toml b/src/modulex_integrations/tools/yelp/dependencies.toml
new file mode 100644
index 0000000..895b8e7
--- /dev/null
+++ b/src/modulex_integrations/tools/yelp/dependencies.toml
@@ -0,0 +1,3 @@
+# Runtime dependencies for the yelp integration.
+# CI assembles this into the root pyproject's [project.optional-dependencies].
+dependencies = []
diff --git a/src/modulex_integrations/tools/yelp/manifest.py b/src/modulex_integrations/tools/yelp/manifest.py
new file mode 100644
index 0000000..2b43ddd
--- /dev/null
+++ b/src/modulex_integrations/tools/yelp/manifest.py
@@ -0,0 +1,155 @@
+"""Yelp integration manifest."""
+from __future__ import annotations
+
+from modulex_integrations.schema import (
+ ActionDefinition,
+ ApiKeyAuthSchema,
+ EnvVar,
+ IntegrationManifest,
+ ParameterDef,
+ SuccessIndicators,
+ TestEndpoint,
+)
+
+__all__ = ["manifest"]
+
+
+manifest = IntegrationManifest(
+ name="yelp",
+ display_name="Yelp",
+ description="Search for businesses, read reviews, and get business details via the Yelp Fusion API",
+ version="1.0.0",
+ author="ModuleX",
+ logo="modulex:yelp",
+ app_url="https://www.yelp.com",
+ categories=["Local Services", "Reviews", "Business Data"],
+ actions=[
+ ActionDefinition(
+ name="search_businesses",
+ description="Search businesses matching given criteria such as location, term, categories, price, and attributes",
+ parameters={
+ "location": ParameterDef(
+ type="string",
+ description="Geographic area to search. Examples: 'New York City', '350 5th Ave, New York, NY 10118'. Required if latitude and longitude are not provided.",
+ ),
+ "latitude": ParameterDef(
+ type="string",
+ description="Latitude of the location to search from. Required if location is not provided.",
+ ),
+ "longitude": ParameterDef(
+ type="string",
+ description="Longitude of the location to search from. Required if location is not provided.",
+ ),
+ "term": ParameterDef(
+ type="string",
+ description="Search term, e.g. 'food' or 'restaurants'. May also be a business name like 'Starbucks'.",
+ ),
+ "max_results": ParameterDef(
+ type="integer",
+ description="Maximum number of businesses to return. Yelp enforces a limit of 1000.",
+ default=200,
+ ),
+ "categories": ParameterDef(
+ type="string",
+ description="Comma-separated category aliases to filter results (e.g. 'discgolf,restaurants'). See Yelp docs for supported categories.",
+ ),
+ "price": ParameterDef(
+ type="string",
+ description="Comma-separated pricing levels: 1 ($), 2 ($$), 3 ($$$), 4 ($$$$). Example: '1,2'.",
+ ),
+ "attributes": ParameterDef(
+ type="string",
+ description="Comma-separated additional filters: hot_and_new, request_a_quote, reservation, waitlist_reservation, deals, gender_neutral_restrooms, open_to_all, wheelchair_accessible.",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="get_business_details",
+ description="Get detailed information about a specific business by its Yelp ID or alias",
+ parameters={
+ "business_id_or_alias": ParameterDef(
+ type="string",
+ description="A unique identifier for a Yelp Business. Can be a 22-character Yelp Business ID or a Yelp Business Alias.",
+ required=True,
+ ),
+ "device_platform": ParameterDef(
+ type="string",
+ description="Determines the platform for mobile_link. Allowed values: android, ios, mobile-generic.",
+ ),
+ "locale": ParameterDef(
+ type="string",
+ description="Locale code in the format {language}_{country} (e.g. en_US).",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="list_business_reviews",
+ description="List the reviews for a specific business",
+ parameters={
+ "business_id_or_alias": ParameterDef(
+ type="string",
+ description="A unique identifier for a Yelp Business. Can be a 22-character Yelp Business ID or a Yelp Business Alias.",
+ required=True,
+ ),
+ "locale": ParameterDef(
+ type="string",
+ description="Locale code in the format {language}_{country} (e.g. en_US).",
+ ),
+ "sort_by": ParameterDef(
+ type="string",
+ description="Sort order for reviews. Allowed values: yelp_sort, newest.",
+ ),
+ },
+ ),
+ ActionDefinition(
+ name="search_businesses_by_phone_number",
+ description="Search for businesses by phone number",
+ parameters={
+ "phone": ParameterDef(
+ type="string",
+ description="Phone number to search for. Must start with + and include the country code, e.g. +14159083801.",
+ required=True,
+ ),
+ "locale": ParameterDef(
+ type="string",
+ description="Locale code in the format {language}_{country} (e.g. en_US).",
+ ),
+ },
+ ),
+ ],
+ auth_schemas=[
+ ApiKeyAuthSchema(
+ display_name="API Key Authentication",
+ description="Authenticate using your Yelp Fusion API key",
+ setup_instructions=[
+ "Go to https://www.yelp.com/developers and sign in",
+ "Navigate to 'Manage App' or create a new app",
+ "Copy your API Key from the app settings",
+ "Paste the API key below",
+ ],
+ setup_environment_variables=[
+ EnvVar(
+ name="YELP_API_KEY",
+ display_name="Yelp API Key",
+ description="Your Yelp Fusion API key from yelp.com/developers",
+ required=True,
+ sensitive=True,
+ sample_format="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ about_url="https://www.yelp.com/developers/v3/manage_app",
+ ),
+ ],
+ test_endpoint=TestEndpoint(
+ url="https://api.yelp.com/v3/businesses/search",
+ method="GET",
+ headers={"Authorization": "Bearer {api_key}"},
+ params={"location": "San Francisco", "limit": "1"},
+ success_indicators=SuccessIndicators(
+ status_codes=[200],
+ response_fields=["businesses"],
+ ),
+ cost_level="free",
+ description="Validates the API key by searching for one business in San Francisco",
+ ),
+ ),
+ ],
+)
diff --git a/src/modulex_integrations/tools/yelp/outputs.py b/src/modulex_integrations/tools/yelp/outputs.py
new file mode 100644
index 0000000..8914590
--- /dev/null
+++ b/src/modulex_integrations/tools/yelp/outputs.py
@@ -0,0 +1,83 @@
+"""Pydantic response models for the yelp integration's @tool functions."""
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field
+
+__all__ = [
+ "BusinessSummary",
+ "GetBusinessDetailsOutput",
+ "ListBusinessReviewsOutput",
+ "ReviewSummary",
+ "SearchBusinessesByPhoneNumberOutput",
+ "SearchBusinessesOutput",
+]
+
+
+class _Base(BaseModel):
+ """Shared config for every output model in this integration."""
+
+ model_config = ConfigDict(extra="forbid")
+
+
+# --- Nested resource models -----------------------------------------------
+
+
+class BusinessSummary(_Base):
+ """A business object returned by Yelp search endpoints."""
+
+ id: str | None = None
+ alias: str | None = None
+ name: str | None = None
+ image_url: str | None = None
+ url: str | None = None
+ review_count: int | None = None
+ categories: list[dict[str, Any]] = Field(default_factory=list)
+ rating: float | None = None
+ coordinates: dict[str, Any] | None = None
+ location: dict[str, Any] | None = None
+ phone: str | None = None
+ display_phone: str | None = None
+ distance: float | None = None
+
+
+class ReviewSummary(_Base):
+ """A review object returned by the reviews endpoint."""
+
+ id: str | None = None
+ url: str | None = None
+ text: str | None = None
+ rating: int | None = None
+ time_created: str | None = None
+ user: dict[str, Any] | None = None
+
+
+# --- Per-action output models ---------------------------------------------
+
+
+class SearchBusinessesOutput(_Base):
+ success: bool
+ error: str | None = None
+ businesses: list[BusinessSummary] = Field(default_factory=list)
+ total: int = 0
+
+
+class GetBusinessDetailsOutput(_Base):
+ success: bool
+ error: str | None = None
+ business: dict[str, Any] | None = None
+
+
+class ListBusinessReviewsOutput(_Base):
+ success: bool
+ error: str | None = None
+ reviews: list[ReviewSummary] = Field(default_factory=list)
+ total: int = 0
+ possible_languages: list[str] = Field(default_factory=list)
+
+
+class SearchBusinessesByPhoneNumberOutput(_Base):
+ success: bool
+ error: str | None = None
+ businesses: list[BusinessSummary] = Field(default_factory=list)
diff --git a/src/modulex_integrations/tools/yelp/tests/__init__.py b/src/modulex_integrations/tools/yelp/tests/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/modulex_integrations/tools/yelp/tests/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/modulex_integrations/tools/yelp/tests/test_yelp.py b/src/modulex_integrations/tools/yelp/tests/test_yelp.py
new file mode 100644
index 0000000..3ffef5c
--- /dev/null
+++ b/src/modulex_integrations/tools/yelp/tests/test_yelp.py
@@ -0,0 +1,182 @@
+"""Happy-path tests for every yelp @tool, plus a manifest sanity check."""
+from __future__ import annotations
+
+import re
+from typing import Any
+
+import pytest
+
+from modulex_integrations.tools.yelp import (
+ TOOLS,
+ get_business_details,
+ list_business_reviews,
+ manifest,
+ search_businesses,
+ search_businesses_by_phone_number,
+)
+from modulex_integrations.tools.yelp.outputs import (
+ GetBusinessDetailsOutput,
+ ListBusinessReviewsOutput,
+ SearchBusinessesByPhoneNumberOutput,
+ SearchBusinessesOutput,
+)
+
+API = "https://api.yelp.com/v3"
+
+_API_KEY = "fake-yelp-api-key"
+
+
+def _args(**extra: Any) -> dict[str, Any]:
+ return dict(api_key=_API_KEY, **extra)
+
+
+# --- Manifest sanity --------------------------------------------------------
+
+
+class TestManifest:
+ def test_manifest_exposes_4_actions(self) -> None:
+ assert len(manifest.actions) == 4
+
+ def test_manifest_actions_match_tools_tuple(self) -> None:
+ assert {a.name for a in manifest.actions} == {t.name for t in TOOLS}
+
+ def test_manifest_has_api_key_auth(self) -> None:
+ assert {a.auth_type for a in manifest.auth_schemas} == {"api_key"}
+
+
+# --- Per-action happy-path tests -------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_search_businesses(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=re.compile(rf"^{re.escape(API)}/businesses/search\?"),
+ json={
+ "total": 1,
+ "businesses": [
+ {
+ "id": "abc123",
+ "alias": "test-biz-sf",
+ "name": "Test Biz",
+ "rating": 4.5,
+ "review_count": 100,
+ "categories": [{"alias": "restaurants", "title": "Restaurants"}],
+ "coordinates": {"latitude": 37.7749, "longitude": -122.4194},
+ "location": {"city": "San Francisco", "state": "CA"},
+ "phone": "+14151234567",
+ "display_phone": "(415) 123-4567",
+ }
+ ],
+ },
+ )
+
+ result_dict = await search_businesses.ainvoke(
+ _args(location="San Francisco", max_results=50)
+ )
+
+ assert isinstance(result_dict, dict)
+ result = SearchBusinessesOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.businesses) == 1
+ assert result.businesses[0].name == "Test Biz"
+ assert result.total == 1
+
+
+@pytest.mark.asyncio
+async def test_search_businesses_validates_empty_api_key() -> None:
+ result_dict = await search_businesses.ainvoke(
+ {"location": "NYC", "api_key": ""}
+ )
+ result = SearchBusinessesOutput.model_validate(result_dict)
+ assert result.success is False
+ assert "API key" in (result.error or "")
+
+
+@pytest.mark.asyncio
+async def test_get_business_details(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/businesses/test-biz-sf",
+ json={
+ # TODO: fill in a representative response shape from the Yelp Fusion API docs
+ "id": "abc123",
+ "alias": "test-biz-sf",
+ "name": "Test Biz",
+ "rating": 4.5,
+ "review_count": 100,
+ "url": "https://www.yelp.com/biz/test-biz-sf",
+ },
+ )
+
+ result_dict = await get_business_details.ainvoke(
+ _args(business_id_or_alias="test-biz-sf")
+ )
+
+ assert isinstance(result_dict, dict)
+ result = GetBusinessDetailsOutput.model_validate(result_dict)
+ assert result.success is True
+ assert result.business is not None
+ assert result.business["name"] == "Test Biz"
+
+
+@pytest.mark.asyncio
+async def test_list_business_reviews(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=f"{API}/businesses/test-biz-sf/reviews",
+ json={
+ # TODO: fill in a representative response shape from the Yelp Fusion API docs
+ "total": 1,
+ "possible_languages": ["en"],
+ "reviews": [
+ {
+ "id": "rev123",
+ "url": "https://www.yelp.com/biz/test-biz-sf?hrid=rev123",
+ "text": "Great place!",
+ "rating": 5,
+ "time_created": "2024-01-15 12:00:00",
+ "user": {"id": "user1", "name": "John D."},
+ }
+ ],
+ },
+ )
+
+ result_dict = await list_business_reviews.ainvoke(
+ _args(business_id_or_alias="test-biz-sf")
+ )
+
+ assert isinstance(result_dict, dict)
+ result = ListBusinessReviewsOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.reviews) == 1
+ assert result.reviews[0].rating == 5
+ assert result.total == 1
+
+
+@pytest.mark.asyncio
+async def test_search_businesses_by_phone_number(httpx_mock): # type: ignore[no-untyped-def]
+ httpx_mock.add_response(
+ method="GET",
+ url=re.compile(rf"^{re.escape(API)}/businesses/search/phone\?"),
+ json={
+ "businesses": [
+ {
+ "id": "abc123",
+ "alias": "test-biz-sf",
+ "name": "Test Biz",
+ "phone": "+14151234567",
+ }
+ ],
+ },
+ )
+
+ result_dict = await search_businesses_by_phone_number.ainvoke(
+ _args(phone="+14151234567")
+ )
+
+ assert isinstance(result_dict, dict)
+ result = SearchBusinessesByPhoneNumberOutput.model_validate(result_dict)
+ assert result.success is True
+ assert len(result.businesses) == 1
+ assert result.businesses[0].phone == "+14151234567"
diff --git a/src/modulex_integrations/tools/yelp/tools.py b/src/modulex_integrations/tools/yelp/tools.py
new file mode 100644
index 0000000..41e55f7
--- /dev/null
+++ b/src/modulex_integrations/tools/yelp/tools.py
@@ -0,0 +1,332 @@
+"""Yelp LangChain @tool functions."""
+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.yelp.outputs import (
+ BusinessSummary,
+ GetBusinessDetailsOutput,
+ ListBusinessReviewsOutput,
+ ReviewSummary,
+ SearchBusinessesByPhoneNumberOutput,
+ SearchBusinessesOutput,
+)
+
+__all__ = [
+ "get_business_details",
+ "list_business_reviews",
+ "search_businesses",
+ "search_businesses_by_phone_number",
+]
+
+_BASE_URL = "https://api.yelp.com/v3"
+_TIMEOUT = 30.0
+_PAGE_SIZE = 50
+
+
+def _headers(api_key: str) -> dict[str, str]:
+ return {
+ "Authorization": f"Bearer {api_key}",
+ "Accept": "application/json",
+ }
+
+
+# --- Input schemas --------------------------------------------------------
+
+
+class SearchBusinessesInput(BaseModel):
+ location: str | None = Field(default=None, description="Geographic area to search. Required if latitude and longitude are not provided.")
+ latitude: str | None = Field(default=None, description="Latitude of the location to search from.")
+ longitude: str | None = Field(default=None, description="Longitude of the location to search from.")
+ term: str | None = Field(default=None, description="Search term, e.g. 'food' or 'restaurants'.")
+ max_results: int = Field(default=200, description="Maximum number of businesses to return (max 1000).")
+ categories: str | None = Field(default=None, description="Comma-separated category aliases to filter results.")
+ price: str | None = Field(default=None, description="Comma-separated pricing levels: 1, 2, 3, 4.")
+ attributes: str | None = Field(default=None, description="Comma-separated additional filters.")
+ api_key: str = Field(description="Yelp Fusion API key")
+
+
+class GetBusinessDetailsInput(BaseModel):
+ business_id_or_alias: str = Field(description="A unique identifier for a Yelp Business (ID or alias).")
+ device_platform: str | None = Field(default=None, description="Platform for mobile_link: android, ios, mobile-generic.")
+ locale: str | None = Field(default=None, description="Locale code (e.g. en_US).")
+ api_key: str = Field(description="Yelp Fusion API key")
+
+
+class ListBusinessReviewsInput(BaseModel):
+ business_id_or_alias: str = Field(description="A unique identifier for a Yelp Business (ID or alias).")
+ locale: str | None = Field(default=None, description="Locale code (e.g. en_US).")
+ sort_by: str | None = Field(default=None, description="Sort order: yelp_sort or newest.")
+ api_key: str = Field(description="Yelp Fusion API key")
+
+
+class SearchBusinessesByPhoneNumberInput(BaseModel):
+ phone: str = Field(description="Phone number starting with + and country code, e.g. +14159083801.")
+ locale: str | None = Field(default=None, description="Locale code (e.g. en_US).")
+ api_key: str = Field(description="Yelp Fusion API key")
+
+
+# --- @tool functions ------------------------------------------------------
+
+
+@tool(args_schema=SearchBusinessesInput)
+@serialize_pydantic_return
+async def search_businesses(
+ api_key: str,
+ location: str | None = None,
+ latitude: str | None = None,
+ longitude: str | None = None,
+ term: str | None = None,
+ max_results: int = 200,
+ categories: str | None = None,
+ price: str | None = None,
+ attributes: str | None = None,
+) -> SearchBusinessesOutput:
+ """Search businesses matching given criteria such as location, term, categories, price, and attributes"""
+ if not api_key or not api_key.strip():
+ return SearchBusinessesOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+ if not location and not (latitude and longitude):
+ return SearchBusinessesOutput(
+ success=False,
+ error="Either 'location' or both 'latitude' and 'longitude' must be provided.",
+ )
+
+ all_businesses: list[dict[str, Any]] = []
+ total = 0
+ offset = 0
+ limit = min(max_results, _PAGE_SIZE)
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ while len(all_businesses) < max_results:
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
+ if location:
+ params["location"] = location
+ if latitude:
+ params["latitude"] = latitude
+ if longitude:
+ params["longitude"] = longitude
+ if term:
+ params["term"] = term
+ if categories:
+ params["categories"] = categories
+ if price:
+ params["price"] = price
+ if attributes:
+ params["attributes"] = attributes
+
+ response = await client.get(
+ f"{_BASE_URL}/businesses/search",
+ headers=_headers(api_key),
+ params=params,
+ )
+ if response.status_code != 200:
+ return SearchBusinessesOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ total = data.get("total", 0)
+ businesses = data.get("businesses", [])
+ if not businesses:
+ break
+ all_businesses.extend(businesses)
+ offset += len(businesses)
+ if offset >= total or len(businesses) < limit:
+ break
+ except httpx.TimeoutException:
+ return SearchBusinessesOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return SearchBusinessesOutput(success=False, error=f"Call failed: {exc}")
+
+ trimmed = all_businesses[:max_results]
+ return SearchBusinessesOutput(
+ success=True,
+ businesses=[
+ BusinessSummary(
+ id=b.get("id"),
+ alias=b.get("alias"),
+ name=b.get("name"),
+ image_url=b.get("image_url"),
+ url=b.get("url"),
+ review_count=b.get("review_count"),
+ categories=b.get("categories", []),
+ rating=b.get("rating"),
+ coordinates=b.get("coordinates"),
+ location=b.get("location"),
+ phone=b.get("phone"),
+ display_phone=b.get("display_phone"),
+ distance=b.get("distance"),
+ )
+ for b in trimmed
+ ],
+ total=total,
+ )
+
+
+@tool(args_schema=GetBusinessDetailsInput)
+@serialize_pydantic_return
+async def get_business_details(
+ business_id_or_alias: str,
+ api_key: str,
+ device_platform: str | None = None,
+ locale: str | None = None,
+) -> GetBusinessDetailsOutput:
+ """Get detailed information about a specific business by its Yelp ID or alias"""
+ if not api_key or not api_key.strip():
+ return GetBusinessDetailsOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+
+ params: dict[str, str] = {}
+ if device_platform:
+ params["device_platform"] = device_platform
+ if locale:
+ params["locale"] = locale
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{_BASE_URL}/businesses/{business_id_or_alias}",
+ headers=_headers(api_key),
+ params=params,
+ )
+ if response.status_code != 200:
+ return GetBusinessDetailsOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return GetBusinessDetailsOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return GetBusinessDetailsOutput(success=False, error=f"Call failed: {exc}")
+
+ return GetBusinessDetailsOutput(success=True, business=data)
+
+
+@tool(args_schema=ListBusinessReviewsInput)
+@serialize_pydantic_return
+async def list_business_reviews(
+ business_id_or_alias: str,
+ api_key: str,
+ locale: str | None = None,
+ sort_by: str | None = None,
+) -> ListBusinessReviewsOutput:
+ """List the reviews for a specific business"""
+ if not api_key or not api_key.strip():
+ return ListBusinessReviewsOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+
+ params: dict[str, str] = {}
+ if locale:
+ params["locale"] = locale
+ if sort_by:
+ params["sort_by"] = sort_by
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{_BASE_URL}/businesses/{business_id_or_alias}/reviews",
+ headers=_headers(api_key),
+ params=params,
+ )
+ if response.status_code != 200:
+ return ListBusinessReviewsOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return ListBusinessReviewsOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return ListBusinessReviewsOutput(success=False, error=f"Call failed: {exc}")
+
+ reviews_raw = data.get("reviews", [])
+ return ListBusinessReviewsOutput(
+ success=True,
+ reviews=[
+ ReviewSummary(
+ id=r.get("id"),
+ url=r.get("url"),
+ text=r.get("text"),
+ rating=r.get("rating"),
+ time_created=r.get("time_created"),
+ user=r.get("user"),
+ )
+ for r in reviews_raw
+ ],
+ total=data.get("total", 0),
+ possible_languages=data.get("possible_languages", []),
+ )
+
+
+@tool(args_schema=SearchBusinessesByPhoneNumberInput)
+@serialize_pydantic_return
+async def search_businesses_by_phone_number(
+ phone: str,
+ api_key: str,
+ locale: str | None = None,
+) -> SearchBusinessesByPhoneNumberOutput:
+ """Search for businesses by phone number"""
+ if not api_key or not api_key.strip():
+ return SearchBusinessesByPhoneNumberOutput(
+ success=False,
+ error="API key is empty. Please configure a valid credential.",
+ )
+
+ params: dict[str, str] = {"phone": phone}
+ if locale:
+ params["locale"] = locale
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
+ response = await client.get(
+ f"{_BASE_URL}/businesses/search/phone",
+ headers=_headers(api_key),
+ params=params,
+ )
+ if response.status_code != 200:
+ return SearchBusinessesByPhoneNumberOutput(
+ success=False,
+ error=f"API error ({response.status_code}): {response.text}",
+ )
+ data = response.json()
+ except httpx.TimeoutException:
+ return SearchBusinessesByPhoneNumberOutput(success=False, error="Request timed out.")
+ except Exception as exc:
+ return SearchBusinessesByPhoneNumberOutput(success=False, error=f"Call failed: {exc}")
+
+ businesses_raw = data.get("businesses", [])
+ return SearchBusinessesByPhoneNumberOutput(
+ success=True,
+ businesses=[
+ BusinessSummary(
+ id=b.get("id"),
+ alias=b.get("alias"),
+ name=b.get("name"),
+ image_url=b.get("image_url"),
+ url=b.get("url"),
+ review_count=b.get("review_count"),
+ categories=b.get("categories", []),
+ rating=b.get("rating"),
+ coordinates=b.get("coordinates"),
+ location=b.get("location"),
+ phone=b.get("phone"),
+ display_phone=b.get("display_phone"),
+ distance=b.get("distance"),
+ )
+ for b in businesses_raw
+ ],
+ )