argile-mcp 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .env
5
+ .env.keys
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ .claude/
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: argile-mcp
3
+ Version: 0.1.1
4
+ Summary: MCP server to talk with the Argile lead bot from Claude
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: httpx>=0.28
7
+ Requires-Dist: logfire[httpx]>=4
8
+ Requires-Dist: mcp>=1.2
9
+ Requires-Dist: pydantic-settings>=2.7
10
+ Requires-Dist: pydantic>=2.7
@@ -0,0 +1,139 @@
1
+ # argile-mcp
2
+
3
+ MCP server to talk with the Argile lead bot directly from Claude (Claude Code, Claude Desktop, or any MCP client).
4
+
5
+ It wraps the bot's web chat API (`https://bot.argile.ai`) so Claude can hold conversations with the bot — which in turn creates leads, quotes, catalogs and projects in the Argile CRM on your behalf.
6
+
7
+ ## Tools
8
+
9
+ | Tool | Description |
10
+ |---|---|
11
+ | `chat(message, conversation_id?)` | Send a message to the bot, get the full reply. Omit `conversation_id` to start a new conversation; reuse the returned id to continue it. |
12
+ | `list_conversations(limit?)` | List your recent conversations (title, counts, timestamps). |
13
+ | `get_conversation(conversation_id)` | Full transcript + linked CRM objects (leads, quotes, …) with direct-access URLs. |
14
+ | `clear_history()` | Clear the active conversation context (destructive). |
15
+
16
+ ## Logging in
17
+
18
+ No token copying needed — the server ships a device-flow login that reuses
19
+ the bot's magic-link page:
20
+
21
+ - **From Claude**: just ask to talk to the bot; if you're not logged in, Claude
22
+ calls `start_login`, gives you a link to open (you sign in with your Argile
23
+ account and click *Autoriser*), then `complete_login` stores the token in
24
+ `~/.argile/credentials.json`.
25
+ - **From a terminal**: `uvx … argile-mcp login` (or `uv run argile-mcp login`
26
+ from a checkout) prints the link and waits for you to authorize.
27
+
28
+ ## Configuration
29
+
30
+ | Env var | Required | Description |
31
+ |---|---|---|
32
+ | `ARGILE_TOKEN` | no | Argile API bearer token; overrides the stored login. Useful for CI. |
33
+ | `ARGILE_BOT_URL` | no | Bot base URL, defaults to `https://bot.argile.ai`. |
34
+ | `ARGILE_CREDENTIALS_PATH` | no | Where the login flow stores the token, defaults to `~/.argile/credentials.json`. |
35
+ | `ARGILE_LOG_LEVEL` | no | Stderr log level of the server process, defaults to `INFO`. |
36
+ | `LOGFIRE_TOKEN` | no | Set to export traces to [Logfire](https://logfire.pydantic.dev) (MCP tool calls, bot HTTP calls, chat spans). Unset → no telemetry leaves the machine. |
37
+
38
+ ## Logging & telemetry
39
+
40
+ stdout carries the MCP protocol, so the server only ever logs to **stderr**.
41
+ With `LOGFIRE_TOKEN` set (we use it internally; end users don't need it),
42
+ Logfire traces every MCP tool call and bot HTTP request, plus an
43
+ `argile-bot chat` span per turn with `tools_used` / `reply_chars` /
44
+ `chart_count` attributes. Logs and spans never contain message or reply
45
+ content — only sizes, ids and tool names.
46
+
47
+ ## Install
48
+
49
+ Requires [uv](https://docs.astral.sh/uv/). The server runs on stdio and is published to PyPI as [`argile-mcp`](https://pypi.org/project/argile-mcp/) (see *Releasing* below).
50
+
51
+ ### Claude Code
52
+
53
+ ```bash
54
+ claude mcp add argile-bot -- uvx argile-mcp
55
+ ```
56
+
57
+ Or per-project, in `.mcp.json` (see [`examples/mcp.json`](examples/mcp.json)):
58
+
59
+ ```json
60
+ {
61
+ "mcpServers": {
62
+ "argile-bot": {
63
+ "command": "uvx",
64
+ "args": ["argile-mcp"]
65
+ }
66
+ }
67
+ }
68
+ ```
69
+
70
+ ### Claude Desktop
71
+
72
+ Add the same server block to `~/Library/Application Support/Claude/claude_desktop_config.json` (see [`examples/claude_desktop_config.json`](examples/claude_desktop_config.json)).
73
+
74
+ ### From a local checkout (development)
75
+
76
+ ```bash
77
+ claude mcp add argile-bot \
78
+ -- uv run --directory /path/to/argile-bot/mcp argile-mcp
79
+ ```
80
+
81
+ ## Example prompts
82
+
83
+ Once installed, just ask Claude:
84
+
85
+ - *"Demande au bot Argile de lister mes conversations récentes."*
86
+ - *"Via le bot Argile : crée un lead pour Marie Dupont, 12 rue des Lilas à Lyon, projet pompe à chaleur."*
87
+ - *"Reprends la conversation `<id>` avec le bot et demande où en est le devis."*
88
+ - *"Montre-moi le transcript de ma dernière conversation avec le bot et les objets CRM liés."*
89
+
90
+ A typical `chat` result:
91
+
92
+ ```json
93
+ {
94
+ "reply": "J'ai créé le lead Marie Dupont ✅ …",
95
+ "conversation_id": "0e8f7c3a-…",
96
+ "active_lead_id": "lead_123",
97
+ "tools_used": ["create_lead"],
98
+ "chart_count": 0
99
+ }
100
+ ```
101
+
102
+ ## Development
103
+
104
+ The package is a uv workspace member; its tests live next to it in
105
+ `mcp/tests/` (also collected by a plain `uv run pytest` from the repo root)
106
+ and include contract tests that bind the MCP client to the real chat router,
107
+ so API drift fails CI.
108
+
109
+ ```bash
110
+ uv sync
111
+ uv run pytest mcp/tests
112
+ ```
113
+
114
+ Smoke-test against the real bot (after `argile-mcp login` or with `ARGILE_TOKEN` set):
115
+
116
+ ```bash
117
+ uv run python mcp/examples/smoke.py "Bonjour, qui es-tu ?"
118
+ ```
119
+
120
+ Inspect the server interactively with the MCP Inspector:
121
+
122
+ ```bash
123
+ npx @modelcontextprotocol/inspector uv run argile-mcp
124
+ ```
125
+
126
+ ## Releasing
127
+
128
+ Bump `version` in `mcp/pyproject.toml`, then push a matching tag:
129
+
130
+ ```bash
131
+ git tag mcp-v0.1.0 && git push origin mcp-v0.1.0
132
+ ```
133
+
134
+ The `release-mcp.yml` workflow runs the MCP tests, builds the package and
135
+ publishes it to PyPI via trusted publishing. One-time setup on pypi.org:
136
+ project **argile-mcp** → *Publishing* → add the GitHub publisher
137
+ (`argile-ai/argile-bot`, workflow `release-mcp.yml`, environment `pypi`),
138
+ and create the matching `pypi` environment in this repo's GitHub settings.
139
+ Only the built wheel goes public — the repo stays private.
@@ -0,0 +1,10 @@
1
+ {
2
+ "mcpServers": {
3
+ "argile-bot": {
4
+ "command": "uvx",
5
+ "args": [
6
+ "argile-mcp"
7
+ ]
8
+ }
9
+ }
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "mcpServers": {
3
+ "argile-bot": {
4
+ "command": "uvx",
5
+ "args": [
6
+ "argile-mcp"
7
+ ]
8
+ }
9
+ }
10
+ }
@@ -0,0 +1,44 @@
1
+ """Smoke test against the real bot: one chat round-trip through the MCP server.
2
+
3
+ Usage (from the repo root, after `argile-mcp login` or with ARGILE_TOKEN set):
4
+ uv run python mcp/examples/smoke.py "Bonjour, qui es-tu ?"
5
+
6
+ Spawns the MCP server as a subprocess over stdio (exactly how Claude runs it),
7
+ lists its tools, sends one chat message and prints the structured reply.
8
+ """
9
+
10
+ import asyncio
11
+ import json
12
+ import os
13
+ import sys
14
+
15
+ from mcp import ClientSession, StdioServerParameters
16
+ from mcp.client.stdio import stdio_client
17
+
18
+
19
+ async def main() -> None:
20
+ message = sys.argv[1] if len(sys.argv) > 1 else "Bonjour, qui es-tu ?"
21
+
22
+ params = StdioServerParameters(
23
+ command="uv", args=["run", "argile-mcp"], env=dict(os.environ)
24
+ )
25
+ async with stdio_client(params) as (read, write):
26
+ async with ClientSession(read, write) as session:
27
+ await session.initialize()
28
+
29
+ tools = (await session.list_tools()).tools
30
+ print(f"tools: {[tool.name for tool in tools]}")
31
+
32
+ print(f"> {message}")
33
+ result = await session.call_tool("chat", {"message": message})
34
+ if result.isError:
35
+ sys.exit(f"chat failed: {result.content[0].text}")
36
+ print(
37
+ json.dumps(
38
+ json.loads(result.content[0].text), indent=2, ensure_ascii=False
39
+ )
40
+ )
41
+
42
+
43
+ if __name__ == "__main__":
44
+ asyncio.run(main())
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "argile-mcp"
3
+ version = "0.1.1"
4
+ description = "MCP server to talk with the Argile lead bot from Claude"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "mcp>=1.2",
8
+ "httpx>=0.28",
9
+ "pydantic>=2.7",
10
+ "pydantic-settings>=2.7",
11
+ "logfire[httpx]>=4",
12
+ ]
13
+
14
+ [project.scripts]
15
+ argile-mcp = "argile_mcp.server:main"
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["src/argile_mcp"]
19
+
20
+ [build-system]
21
+ requires = ["hatchling"]
22
+ build-backend = "hatchling.build"
@@ -0,0 +1,8 @@
1
+ """MCP server for talking with the Argile lead bot from Claude."""
2
+
3
+ from argile_mcp.client import ArgileBotClient
4
+ from argile_mcp.config import Settings
5
+ from argile_mcp.errors import ArgileBotError
6
+ from argile_mcp.schemas import ChatResult
7
+
8
+ __all__ = ["ArgileBotClient", "ArgileBotError", "ChatResult", "Settings"]
@@ -0,0 +1,66 @@
1
+ """Device-flow login against the bot's /auth/mcp endpoints.
2
+
3
+ Mirrors the bot's magic-link flow: start a login session, send the user to
4
+ the returned URL to authorize in a browser (Clerk), then poll until the bot
5
+ hands out the token — exactly once.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+
11
+ import httpx
12
+
13
+ from argile_mcp.errors import ArgileBotError
14
+ from argile_mcp.schemas import DeviceAuthStart
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ POLL_INTERVAL = 2.0
19
+ POLL_TIMEOUT = 180.0
20
+
21
+
22
+ async def start_login(
23
+ base_url: str, transport: httpx.AsyncBaseTransport | None = None
24
+ ) -> DeviceAuthStart:
25
+ """Open a login session; the user must authorize on the returned URL."""
26
+ async with httpx.AsyncClient(timeout=30.0, transport=transport) as client:
27
+ response = await client.post(f"{base_url.rstrip('/')}/auth/mcp/start")
28
+ if response.status_code != 200:
29
+ raise ArgileBotError(
30
+ f"HTTP {response.status_code} from bot: {response.text[:500]}"
31
+ )
32
+ start = DeviceAuthStart.model_validate(response.json())
33
+ logger.info("login session opened at %s", base_url)
34
+ return start
35
+
36
+
37
+ async def poll_token(
38
+ base_url: str,
39
+ poll_key: str,
40
+ timeout: float = POLL_TIMEOUT,
41
+ interval: float = POLL_INTERVAL,
42
+ transport: httpx.AsyncBaseTransport | None = None,
43
+ ) -> str:
44
+ """Wait for the user to authorize, then collect the token (one-time)."""
45
+ deadline = asyncio.get_running_loop().time() + timeout
46
+ async with httpx.AsyncClient(timeout=30.0, transport=transport) as client:
47
+ while True:
48
+ response = await client.get(
49
+ f"{base_url.rstrip('/')}/auth/mcp/token",
50
+ params={"poll_key": poll_key},
51
+ )
52
+ if response.status_code == 200:
53
+ logger.info("login authorized, token received")
54
+ return response.json()["token"]
55
+ if response.status_code != 202:
56
+ logger.warning("login poll failed: HTTP %d", response.status_code)
57
+ raise ArgileBotError(
58
+ f"Login failed (HTTP {response.status_code}): {response.text[:500]}"
59
+ )
60
+ if asyncio.get_running_loop().time() >= deadline:
61
+ logger.warning("login timed out after %.0fs", timeout)
62
+ raise ArgileBotError(
63
+ "Login not completed in time. Start a new login and open "
64
+ "the URL in your browser."
65
+ )
66
+ await asyncio.sleep(interval)
@@ -0,0 +1,145 @@
1
+ """Thin async HTTP client over the Argile bot's web endpoints.
2
+
3
+ Logging never includes message or reply content (user data) — only sizes,
4
+ ids and tool names.
5
+ """
6
+
7
+ import logging
8
+ from typing import Any
9
+
10
+ import httpx
11
+ import logfire
12
+ from pydantic import TypeAdapter
13
+
14
+ from argile_mcp.config import Settings
15
+ from argile_mcp.credentials import load_token
16
+ from argile_mcp.errors import ArgileBotError
17
+ from argile_mcp.schemas import (
18
+ ChatResult,
19
+ ConversationDetail,
20
+ ConversationSummary,
21
+ StatusResult,
22
+ )
23
+ from argile_mcp.sse import fold_event, parse_sse_frame
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ _SUMMARIES = TypeAdapter(list[ConversationSummary])
28
+
29
+
30
+ class ArgileBotClient:
31
+ """Client for the bot's chat and conversations API.
32
+
33
+ `transport` is injectable so tests can bind the client straight to the
34
+ bot's ASGI app without a network hop.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ base_url: str | None = None,
40
+ token: str | None = None,
41
+ transport: httpx.AsyncBaseTransport | None = None,
42
+ ):
43
+ settings = Settings()
44
+ self.base_url = (base_url or settings.argile_bot_url).rstrip("/")
45
+ self.token = (
46
+ token
47
+ or settings.argile_token
48
+ or load_token(settings.argile_credentials_path)
49
+ or ""
50
+ )
51
+ self.transport = transport
52
+ self.chat_timeout = httpx.Timeout(10.0, read=settings.chat_read_timeout)
53
+ if not self.token:
54
+ raise ArgileBotError(
55
+ "Not logged in. Use the start_login/complete_login tools (or "
56
+ "run `argile-mcp login`), or set ARGILE_TOKEN."
57
+ )
58
+
59
+ def _http(self, timeout: httpx.Timeout | float) -> httpx.AsyncClient:
60
+ return httpx.AsyncClient(timeout=timeout, transport=self.transport)
61
+
62
+ @property
63
+ def _headers(self) -> dict[str, str]:
64
+ return {"Authorization": f"Bearer {self.token}"}
65
+
66
+ async def chat(
67
+ self, message: str, conversation_id: str | None = None
68
+ ) -> ChatResult:
69
+ """Send one message and read the streamed reply to completion."""
70
+ form: dict[str, str] = {"message": message}
71
+ if conversation_id:
72
+ form["conversation_id"] = conversation_id
73
+
74
+ result = ChatResult()
75
+ logger.info(
76
+ "chat turn started: conversation=%s message_chars=%d",
77
+ conversation_id or "new",
78
+ len(message),
79
+ )
80
+ with logfire.span(
81
+ "argile-bot chat",
82
+ conversation_id=conversation_id,
83
+ message_chars=len(message),
84
+ ) as span:
85
+ async with self._http(self.chat_timeout) as client:
86
+ async with client.stream(
87
+ "POST",
88
+ f"{self.base_url}/chat/stream",
89
+ data=form,
90
+ headers=self._headers,
91
+ ) as response:
92
+ if response.status_code != 200:
93
+ body = (await response.aread()).decode(errors="replace")
94
+ logger.warning(
95
+ "chat turn failed: HTTP %d", response.status_code
96
+ )
97
+ raise ArgileBotError(
98
+ f"HTTP {response.status_code} from bot: {body[:500]}"
99
+ )
100
+ async for line in response.aiter_lines():
101
+ event = parse_sse_frame(line)
102
+ if event is not None:
103
+ fold_event(result, event)
104
+ span.set_attribute("tools_used", result.tools_used)
105
+ span.set_attribute("reply_chars", len(result.reply))
106
+ span.set_attribute("chart_count", result.chart_count)
107
+ logger.info(
108
+ "chat turn done: conversation=%s tools=%s reply_chars=%d charts=%d",
109
+ result.conversation_id,
110
+ result.tools_used,
111
+ len(result.reply),
112
+ result.chart_count,
113
+ )
114
+ return result
115
+
116
+ async def _get_json(self, path: str, params: dict[str, Any] | None = None) -> Any:
117
+ async with self._http(30.0) as client:
118
+ response = await client.get(
119
+ f"{self.base_url}{path}", params=params, headers=self._headers
120
+ )
121
+ if response.status_code != 200:
122
+ logger.warning("GET %s failed: HTTP %d", path, response.status_code)
123
+ raise ArgileBotError(
124
+ f"HTTP {response.status_code} from bot: {response.text[:500]}"
125
+ )
126
+ return response.json()
127
+
128
+ async def list_conversations(self, limit: int = 20) -> list[ConversationSummary]:
129
+ payload = await self._get_json("/conversations", params={"limit": limit})
130
+ return _SUMMARIES.validate_python(payload)
131
+
132
+ async def get_conversation(self, conversation_id: str) -> ConversationDetail:
133
+ payload = await self._get_json(f"/conversations/{conversation_id}")
134
+ return ConversationDetail.model_validate(payload)
135
+
136
+ async def clear_history(self) -> StatusResult:
137
+ async with self._http(30.0) as client:
138
+ response = await client.delete(
139
+ f"{self.base_url}/chat/history", headers=self._headers
140
+ )
141
+ if response.status_code != 200:
142
+ raise ArgileBotError(
143
+ f"HTTP {response.status_code} from bot: {response.text[:500]}"
144
+ )
145
+ return StatusResult.model_validate(response.json())
@@ -0,0 +1,33 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic_settings import BaseSettings
4
+
5
+
6
+ class Settings(BaseSettings):
7
+ """MCP server configuration, read from the environment (or a .env file).
8
+
9
+ Instantiate at client construction time, not import time, so each server
10
+ process (and each test) picks up the current environment.
11
+ """
12
+
13
+ # extra="ignore": a .env may also hold the bot's own variables (e.g. when
14
+ # running from the argile-bot checkout).
15
+ model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
16
+
17
+ # Argile API bearer token of the user the bot acts for. Optional when a
18
+ # token has been stored by the login flow (see credentials.py).
19
+ argile_token: str = ""
20
+
21
+ # Where the login flow stores the token it obtains.
22
+ argile_credentials_path: Path = Path.home() / ".argile" / "credentials.json"
23
+
24
+ # Bot base URL.
25
+ argile_bot_url: str = "https://bot.argile.ai"
26
+
27
+ # Reading the whole SSE stream can take a while: the agent may run
28
+ # several CRM tool calls before answering.
29
+ chat_read_timeout: float = 300.0
30
+
31
+ # Stderr log level for the server process. Logfire export is controlled
32
+ # separately by LOGFIRE_TOKEN (unset → no telemetry leaves the machine).
33
+ argile_log_level: str = "INFO"
@@ -0,0 +1,25 @@
1
+ """Local storage for the token obtained by the login flow."""
2
+
3
+ import json
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ def load_token(path: Path) -> str | None:
11
+ """Read the stored token, or None when absent or unreadable."""
12
+ try:
13
+ data = json.loads(path.read_text())
14
+ except (OSError, json.JSONDecodeError):
15
+ return None
16
+ token = data.get("token") if isinstance(data, dict) else None
17
+ return token if isinstance(token, str) and token else None
18
+
19
+
20
+ def save_token(path: Path, token: str) -> None:
21
+ """Store the token, owner-readable only."""
22
+ path.parent.mkdir(parents=True, exist_ok=True)
23
+ path.write_text(json.dumps({"token": token}))
24
+ path.chmod(0o600)
25
+ logger.info("token stored in %s", path)
@@ -0,0 +1,2 @@
1
+ class ArgileBotError(Exception):
2
+ """The bot returned an error (HTTP or in-stream ``error`` event)."""
@@ -0,0 +1,152 @@
1
+ """Pydantic models for the bot's SSE wire contract and MCP tool results."""
2
+
3
+ from datetime import datetime
4
+ from typing import Annotated, Any, Literal
5
+ from uuid import UUID
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field, computed_field
8
+
9
+ # Enum-ish fields (channel, role, linked-object type) and chart specs are kept
10
+ # open (str / dict) on purpose: the bot owns those vocabularies and may grow
11
+ # them; the MCP passes them through. Everything structural is typed, and the
12
+ # contract tests in argile-bot pin these models to the real routers.
13
+
14
+
15
+ class TextEvent(BaseModel):
16
+ type: Literal["text"]
17
+ text: str
18
+
19
+
20
+ class ToolEvent(BaseModel):
21
+ type: Literal["tool"]
22
+ name: str
23
+
24
+
25
+ class ChartEvent(BaseModel):
26
+ type: Literal["chart"]
27
+ spec: dict[str, Any] = Field(default_factory=dict)
28
+
29
+
30
+ class DoneEvent(BaseModel):
31
+ model_config = ConfigDict(populate_by_name=True)
32
+
33
+ type: Literal["done"]
34
+ conversation_id: str | None = Field(default=None, alias="conversationId")
35
+ active_lead_id: str | None = Field(default=None, alias="activeLeadId")
36
+
37
+
38
+ class ErrorEvent(BaseModel):
39
+ type: Literal["error"]
40
+ message: str = "Unknown bot error"
41
+ detail: str | None = None
42
+
43
+
44
+ StreamEvent = Annotated[
45
+ TextEvent | ToolEvent | ChartEvent | DoneEvent | ErrorEvent,
46
+ Field(discriminator="type"),
47
+ ]
48
+
49
+
50
+ class ChatResult(BaseModel):
51
+ """The bot's reply to one chat turn, accumulated from the SSE stream.
52
+
53
+ `charts` holds the raw chart specs for callers that want them but is
54
+ excluded from dumps — MCP results only carry the count.
55
+ """
56
+
57
+ reply: str = ""
58
+ conversation_id: str | None = None
59
+ active_lead_id: str | None = None
60
+ tools_used: list[str] = Field(default_factory=list)
61
+ charts: list[dict[str, Any]] = Field(default_factory=list, exclude=True)
62
+
63
+ @computed_field
64
+ @property
65
+ def chart_count(self) -> int:
66
+ return len(self.charts)
67
+
68
+
69
+ # --- /conversations payloads (mirrors src/conversations.py in argile-bot) ----
70
+ # extra="allow" so new API fields flow through to the model instead of being
71
+ # silently dropped.
72
+
73
+
74
+ class TextStep(BaseModel):
75
+ type: Literal["text"]
76
+ text: str
77
+
78
+
79
+ class ToolStep(BaseModel):
80
+ type: Literal["tool"]
81
+ name: str
82
+
83
+
84
+ class ChartStep(BaseModel):
85
+ type: Literal["chart"]
86
+ spec: dict[str, Any] = Field(default_factory=dict)
87
+
88
+
89
+ TranscriptStep = Annotated[TextStep | ToolStep | ChartStep, Field(discriminator="type")]
90
+
91
+
92
+ class LinkedObject(BaseModel):
93
+ """A CRM object linked to a conversation, with a direct-access URL."""
94
+
95
+ model_config = ConfigDict(extra="allow")
96
+
97
+ id: UUID
98
+ type: str
99
+ object_id: str
100
+ label: str | None = None
101
+ url: str | None = None
102
+ created_at: datetime
103
+
104
+
105
+ class Message(BaseModel):
106
+ model_config = ConfigDict(extra="allow")
107
+
108
+ id: UUID
109
+ role: str
110
+ content: str
111
+ steps: list[TranscriptStep] | None = None
112
+ attachment_url: str | None = None
113
+ created_at: datetime
114
+
115
+
116
+ class ConversationBase(BaseModel):
117
+ model_config = ConfigDict(extra="allow")
118
+
119
+ id: UUID
120
+ sender_id: str
121
+ channel: str
122
+ lead_id: str | None = None
123
+ user_email: str | None = None
124
+ created_at: datetime
125
+ last_message_at: datetime
126
+ ended_at: datetime | None = None
127
+
128
+
129
+ class ConversationSummary(ConversationBase):
130
+ title: str
131
+ message_count: int
132
+ linked_object_count: int
133
+
134
+
135
+ class ConversationDetail(ConversationBase):
136
+ messages: list[Message]
137
+ linked_objects: list[LinkedObject]
138
+
139
+
140
+ class StatusResult(BaseModel):
141
+ status: str
142
+
143
+
144
+ class DeviceAuthStart(BaseModel):
145
+ """Handshake material returned by the bot's /auth/mcp/start endpoint.
146
+
147
+ `poll_key` is a one-time secret: hand it back to `complete_login` after
148
+ the user has authorized on `login_url`.
149
+ """
150
+
151
+ poll_key: str
152
+ login_url: str