# Leadify MCP Server

Serveur MCP (Model Context Protocol) pour l'API Leadify. Expose les endpoints REST de Leadify sous forme de tools utilisables depuis Claude Desktop, Claude Code, Cursor ou tout client compatible MCP.

Package npm : [`@agifyai/leadify-mcp`](https://www.npmjs.com/package/@agifyai/leadify-mcp)

`get_mcp_runtime_info` est le diagnostic de livraison en lecture seule. Il expose la version exacte du package et du serveur, puis vérifie qu'un `organization_id` explicitement choisi est accessible à la clé configurée. Il ne lit aucun prospect et n'effectue aucune écriture, aucun envoi, aucune publication ni activation.

---

## 📦 Pour les utilisateurs

Aucun clone, aucun build. `npx` télécharge la dernière version à chaque démarrage de session MCP.

### Pré-requis

- [Node.js](https://nodejs.org/) ≥ 18 (`node --version` pour vérifier)
- Une clé API Leadify (demander à l'équipe ou la générer dans l'app)

### Claude Code

```bash
claude mcp add leadify -e LEADIFY_API_KEY=votre-clé-api -- npx -y @agifyai/leadify-mcp@latest
```

> Le `--` est nécessaire pour que `claude mcp add` ne tente pas d'interpréter le `-y` de `npx` comme une de ses propres options.

Vérifier que c'est bien branché :

```bash
claude mcp list
```

Tu dois voir `leadify` dans la liste. Dans une session Claude Code, demande "appelle test_api_key" pour valider.

### Claude Desktop

Ouvrir le fichier de configuration :

- **macOS** : `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Windows** : `%APPDATA%\Claude\claude_desktop_config.json`

Ajouter une entrée `leadify` dans `mcpServers` :

```json
{
  "mcpServers": {
    "leadify": {
      "command": "npx",
      "args": ["-y", "@agifyai/leadify-mcp@latest"],
      "env": {
        "LEADIFY_API_KEY": "votre-clé-api"
      }
    }
  }
}
```

Redémarrer Claude Desktop. Les tools Leadify apparaissent (icône marteau dans la zone de saisie).

### Mise à jour automatique

Le tag `@latest` force `npx` à vérifier la dernière version publiée à chaque lancement de session. Quand un nouveau tool est mergé sur `main` et publié, il est dispo dès la **session suivante** — sans `git pull`, sans rebuild, sans rien.

Pour forcer un rafraîchissement immédiat sans attendre le cache npm :

```bash
npm cache clean --force
```

### Dépannage

| Symptôme | Cause / solution |
|---|---|
| `LEADIFY_API_KEY environment variable is required` | La clé n'est pas passée. Vérifier le `-e` (Claude Code) ou le bloc `env` (Claude Desktop). |
| `401 Unauthorized` sur tous les tools | Clé API invalide ou révoquée. Tester avec `test_api_key`. |
| Le serveur ne démarre pas | Vérifier que `npx -y @agifyai/leadify-mcp@latest` tourne en standalone. Si erreur réseau, vérifier l'accès à `registry.npmjs.org`. |
| Tool ajouté côté équipe mais pas visible chez moi | Quitter complètement le client (Claude Desktop : ⌘Q ; Claude Code : ferme la session) et relancer. |

---

## 🛠️ Pour les contributeurs

### Setup local

```bash
git clone git@github.com:AgifyAI/mcp_leadify.git
cd mcp_leadify
npm install
npm run build
```

Brancher Claude Code sur ta build locale (en plus de la version npm si tu veux comparer) :

```bash
claude mcp add leadify-dev -e LEADIFY_API_KEY=votre-clé-api -- node /chemin/absolu/vers/mcp_leadify/dist/index.js
```

Mode watch :

```bash
npm run dev
```

Canari de vérité commerciale en lecture seule :

```bash
LEADIFY_CANARY_ORGANIZATION_ID=org-id npm run canary:commercial
```

Le canari sélectionne une organisation et un groupe accessibles, relit la même source deux fois, ne publie aucun message et n'ajoute aucune activité. Les variables `LEADIFY_CANARY_LEAD_GROUP_ID`, `LEADIFY_CANARY_EMAIL`, `LEADIFY_CANARY_LINKEDIN_URL` et `LEADIFY_CANARY_LEAD_ID` permettent de fixer une cible déjà autorisée ; la clé et le contenu des conversations ne sont jamais affichés.

### Architecture

```
src/
├── index.ts          # entrée stdio (shebang + transport)
├── server.ts         # création du McpServer + register* de chaque module
├── client.ts         # LeadifyClient (singleton, lit LEADIFY_API_KEY)
├── types.ts          # toolResult, handleToolError, LeadifyApiError
└── tools/
    ├── auth.ts
    ├── leads.ts
    ├── events.ts
    ├── campaigns.ts
    ├── context_workspace.ts
    ├── context_entities.ts
    ├── personas.ts
    └── ...           # un fichier = un domaine fonctionnel
```

Un tool = un appel à `server.tool(name, description, zodSchema, async handler)`. Voir `src/tools/auth.ts` pour le plus simple.

### Ajouter un tool

1. Coder le tool dans le fichier de domaine pertinent (`src/tools/<domaine>.ts`), ou créer un nouveau fichier.
2. Si nouveau fichier : exporter `registerXxxTools(server)` et l'appeler depuis `src/server.ts`.
3. Vérifier que ça compile :
   ```bash
   npm run build
   ```
4. Tester en local (cf. setup ci-dessus).
5. Bumper la version et publier (cf. section suivante).

### Publier une nouvelle version

Chaque push sur `main` déclenche automatiquement une publication npm distincte. Le workflow sérialise les publications, exécute les tests, puis :

- publie la version de `package.json` si elle est supérieure à la version npm courante (pour un changement minor ou major intentionnel) ;
- sinon, incrémente automatiquement le patch de la dernière version npm ;
- publie avec Trusted Publishing OIDC, puis ajoute un tag Git `vX.Y.Z` sur le SHA exact publié.

Il n'est donc pas nécessaire de bumper le patch à la main. Pour annoncer volontairement une nouvelle minor ou major, modifier la version source avant le merge :

```bash
npm version minor --no-git-tag-version    # 8.3.x → 8.4.0
npm version major --no-git-tag-version    # 8.x → 9.0.0
```

`npm version` crée un commit + un tag git automatiquement.

Vérifier la publication :

```bash
npm view @agifyai/leadify-mcp version
```

Et le run du workflow : https://github.com/AgifyAI/mcp_leadify/actions

### Comment marche la CI

`.github/workflows/publish.yml` se déclenche sur push `main` :

1. Checkout + install Node 20.
2. `npm ci` pour installer les deps.
3. Calcule une version inédite à partir de la version npm courante et de l'éventuelle intention minor/major dans `package.json`.
4. Exécute la suite de tests complète.
5. Upgrade npm vers ≥ 11.5.1 (requis pour Trusted Publishing), puis publie via OIDC. La provenance Sigstore explicite reste désactivée tant que npm ne la supporte pas pour les dépôts GitHub privés.
6. Pose le tag de version sur le commit `main` publié.

L'authentification npm passe par **Trusted Publishing (OIDC)** : pas de token stocké, GitHub Actions s'authentifie directement auprès de npm via la permission `id-token: write` du workflow. La trust relation est configurée sur la [page npm du package](https://www.npmjs.com/package/@agifyai/leadify-mcp/access) (Trusted Publisher : `AgifyAI/mcp_leadify` / `publish.yml`).

Conséquences pratiques :
- Pas de secret `NPM_TOKEN` à rotater.
- Le workflow ne peut publier que depuis ce repo + ce fichier de workflow exact. Renommer `publish.yml` casse la trust → mettre à jour côté npm si besoin.

### Conventions

- **Versionning** : suivre semver. Ajout de tool = `patch` (rétrocompatible). Renommage / suppression / signature breaking = `major`.
- **Description des tools** : verbeuse et précise — c'est ce que le modèle lit pour décider d'utiliser le tool. Voir les tools `outreach_*` pour des exemples détaillés.
- **Erreurs** : toujours wrapper le handler dans `try / catch` et retourner `handleToolError(error)` en cas d'échec — ça normalise les erreurs API en réponse MCP propre.

---

## Sémantique des événements

Une participation est rattachée à l’entreprise canonique (`Account`), même
lorsqu’un `lead_id` est fourni au tool. `get_leads.event_filter` et
`list_event_participations` ajoutent explicitement `VERIFIED` lorsque le statut
n’est pas fourni. Pour relire des faits `UNVERIFIED`, `DISPUTED` ou `REFUTED`,
l’agent doit demander ces statuts explicitement. Une donnée incertaine ou
l’absence d’un enregistrement ne constitue jamais une preuve de présence ou de
non-participation.

Les corrections ajoutent un fait immuable et mettent à jour le snapshot
courant dans la même opération ; elles exigent un motif. Il n’existe aucun tool
de suppression de participation ou de preuve. Archiver un événement le retire
des sélecteurs courants sans effacer son historique. Les champs legacy
`congress_presence`, `next_congress`, `next_congress_date` et `card_congress`
restent disponibles pour compatibilité/export, mais ne sont jamais synchronisés
depuis ce modèle.

Il n’existe plus de score de confiance événementiel : le statut et ses preuves
portent seuls la validation utile. `Event.description` explique l’audience et
l’utilité de l’événement ; `activity_summary` explique ce que l’entreprise y
fait, présente ou cible. `proof_excerpt` reste l’extrait exact destiné aux
agents et à l’audit. `record_event_participations` sert aussi bien à créer une
participation qu’à ajouter une nouvelle preuve ou à corriger le snapshot.

Pour `add_leads` et `update_lead`, `location` utilise l’objet canonique
`{ city, region?, postalCode?, countryCode, street? }`. `city` et le code pays
ISO-2 `countryCode` sont obligatoires. La projection `geo` est calculée par
Leadify et ne doit jamais être envoyée par un agent MCP.

## Tools disponibles

| Tool | Description |
|------|-------------|
| `test_api_key` | Vérifier que la clé API configurée est valide (health check). |
| `list_lead_groups` | Lister les groupes accessibles d'une organisation explicitement sélectionnée, en vue compacte. |
| `get_lead_group` | Consulter les métadonnées compactes d'un groupe accessible par son ID. |
| `create_lead_group` | Créer un groupe, choisir son type canonique optionnel et rattacher atomiquement `persona_id` avec l’`offer_id` ACTIVE du même tenant. |
| `update_lead_group` | Modifier un groupe avec réconciliation du schéma ; lorsque le serveur exige un contexte complet, transmettre ensemble `persona_id` et `offer_id` sans inférer l’offre. |
| `add_leads` | Import plat générique vers un groupe : ne crée aucune relation d’emploi, aucun rattachement parent ni identité canonique. Ne jamais l’utiliser pour rattacher une Personne à une Company (voir `upsert_person_leads_with_employment`). |
| `upsert_person_leads_with_employment` | Créer ou réconcilier atomiquement des Person Leads, PERSON, EMPLOYED_BY, projections et historique, sans effet commercial. |
| `create_person_employment_evidence` / `get_person_employment_evidence` | Créer puis relire une preuve d’emploi individuelle tenant-scopée ; transmettre l’`evidence.id` retourné tel quel à l’upsert Person. |
| `get_leads` | Rechercher et lister des leads avec filtres, recherche et pagination, dont le critère composé `event_filter`. |
| `get_lead` | Récupérer les détails complets d'un lead par son ID. |
| `update_lead` | Mettre à jour un ou plusieurs champs d'un lead existant. |
| `clear_lead_field` | Effacer irréversiblement une valeur stockée, sans modifier le schéma du groupe. Exige la confirmation explicite `clear_lead_field`. |
| `list_events` | Lister les événements tenant-scoped ; les événements archivés sont masqués par défaut. |
| `get_event` | Lire un événement partagé et son état d’archivage. |
| `create_event` | Créer un événement partagé avec une clé d’idempotence. |
| `update_event` | Modifier ou archiver sans suppression un événement partagé, avec version attendue. |
| `list_event_participations` | Lister les participations d’entreprise ; filtre explicitement `VERIFIED` par défaut. |
| `get_event_participation` | Lire un snapshot courant et son historique ; `VERIFIED` par défaut, statut incertain à demander explicitement. |
| `record_event_participations` | Valider à blanc ou enregistrer un lot idempotent de 1 à 100 observations/corrections. |
| `preview_reset_sequence_messages` / `execute_reset_sequence_messages` | Prévisualiser puis effacer irréversiblement les messages générés d'un prospect, d'une liste explicite ou d'un groupe explicite. L'exécution exige le digest de preview et une clé d'idempotence. |
| `preview_reset_ai_fields` / `execute_reset_ai_fields` | Prévisualiser puis effacer irréversiblement les champs IA d'un prospect, d'une liste explicite ou d'un groupe explicite, sans toucher aux contacts, flags manuels, activités ou campagnes. |
| `delete_leads` | Supprimer définitivement des leads par leurs IDs. |
| `update_schema` | Ajouter ou modifier les définitions de champs d'un groupe. |
| `delete_columns` | Supprimer des colonnes du schéma et des données d'un groupe. Avec `force: true`, supprimer aussi une clé de données orpheline absente du schéma. |
| `update_hidden_columns` | Afficher ou masquer des colonnes dans la vue tableau (réversible). |
| `list_crm_schema_packs` | Lister le catalogue canonique des CRM Schema Packs, leurs domaines et contraintes de compatibilité. |
| `add_campaign_log` | Enregistrer une entrée de log de campagne pour un lead. |
| `get_campaign_logs` | Récupérer les logs de campagne avec filtres et pagination. |
| `delete_campaign_log` | Supprimer une entrée de log de campagne. |
| `update_campaign_stats` | Mettre à jour les statistiques d'email d'une campagne pour un groupe de leads. |
| `create_campaign` | Créer une campagne DRAFT mono-canal rattachée à un Lead Group qui possède déjà sa paire Persona–Offre. Aucun `persona_id` ou contexte indépendant n’est accepté au niveau Campagne. `channel` (`LINKEDIN` ou `EMAIL`) est obligatoire ; `start_at` inclusif, `end_at` exclusif et `timezone` IANA configurent la fenêtre métier. À `end_at`, Leadify met la campagne en pause réversible (`WINDOW_END`) sans la clôturer. |
| `update_campaign` / `update_campaign_configuration` | Modifier le nom, la description et la fenêtre de toute campagne encore `OPEN`; le canal reste modifiable uniquement en DRAFT. Prolonger `end_at` dans le futur ou le supprimer relance automatiquement une pause `WINDOW_END` après validation du fournisseur, mais jamais une pause manuelle. La fenêtre demandée reste enregistrée si cette validation échoue. La Persona est héritée du Lead Group et n’est pas modifiable au niveau Campagne. |
| `delete_campaign` | Supprimer irréversiblement une campagne `DRAFT` ou `PAUSED` encore `OPEN`, avec la confirmation explicite `delete_campaign`. Une campagne active ou clôturée est refusée, et aucun envoi n’est déclenché. |
| `list_campaigns` | Lister compactement les campagnes d'une organisation explicitement sélectionnée, avec état effectif, fenêtre, motif de pause et clôture. |
| `get_campaign` | Récupérer les détails d'une campagne, son état effectif, sa clôture et son éventuel rapport final figé, ainsi que ses KPIs temps réel. |
| `update_campaign_status` | Changer le statut d'une campagne. `PAUSED` est une pause manuelle réversible ; `COMPLETED` déclenche la clôture définitive terminale et son rapport final immuable. |
| `get_campaign_audience` | Lire l'audience explicitement enrôlée d'une campagne dans une organisation explicitement sélectionnée, avec les seuls signaux nécessaires à la décision, sans contenu de message ni envoi. |
| `preview_unenroll_campaign_audience` | Prévisualiser de façon déterministe les membres explicites sans message pour le canal de leur campagne ou sans aucun canal de contact. Retourne les `lead_ids` exacts, sans mutation. |
| `unenroll_campaign_audience` | Désenrôler uniquement la liste complète de `lead_ids` renvoyée par le preview courant. Relit et refuse toute sélection partielle, étendue ou périmée ; aucun envoi. |
| `export_campaign` | Exporter les statistiques en CSV ; après clôture définitive, l'export provient du rapport final figé. |
| `signal_upsert` | Créer ou mettre à jour un signal de business intelligence (INFO, CRITICAL, GOLDEN). Remet le state à `active`. |
| `signal_expire` | Expirer un signal (événement périmé). Flip de `state` uniquement. |
| `signal_disable` | Désactiver un signal (faux positif / écarté manuellement). Flip de `state` uniquement. |
| `signal_delete` | Supprimer définitivement un signal (cas rare : donnée erronée, doublon). |
| `add_activity` | Journaliser une interaction prospect (LinkedIn, email, call) dans le feed du lead. |
| `crm_lookup_person` | Lire la personne, les deals et le stage CRM d'une cible explicite, sans écrire dans le CRM. |
| `email_read_thread` | Lire un thread email borné et ses réponses, exclusions de délivrabilité et ambiguïtés. |
| `linkedin_read_conversation` | Lire la conversation LinkedIn bornée d'un profil explicitement sélectionné. |
| `unipile_read_messages` | Lire les messages LinkedIn Unipile bornés d'un profil explicitement sélectionné. |
| `leadify_read_activity_feed` | Lire l'audit borné d'un lead ; le feed ne constitue jamais une preuve de réponse. |
| `get_commercial_truth` | Réconcilier CRM, email, LinkedIn, Unipile et activité avec un statut, une fraîcheur et une action recommandée ; aucune écriture ni envoi. |
| `compile_context_pack` | Compiler en lecture seule un Context Pack canonique depuis une cible explicite (`organization`, `lead_group`, `campaign`, `account`, `person`) ou un couple lead + campagne pour `WRITE_OUTREACH`. Retourne publications courantes, sources, freshness, contradictions et provenance, sans choisir de cible implicite. |
| `get_claim_contradiction` | Lire une contradiction canonique tenant-scopée avec ses deux claims immuables, leurs preuves, sa version et son éventuelle résolution auditée. |
| `resolve_claim_contradiction` | Arbitrer explicitement une contradiction relue par sélection d’un claim ou coexistence, avec justification, version, état attendu et clé d’idempotence ; aucun effet commercial. |
| `resolve_claim_contradictions_batch` | Soumettre jusqu’à 100 arbitrages explicites et relire chaque résultat ordonné, sans choix automatique ni résolution implicite. |
| `get_context_workspace` | Lire le seul Context Workspace encore exposé et versionné : le `company_brain` d'une organisation explicitement sélectionnée. Retourne le brouillon et la dernière version publiée ; l'historique GTM reste stocké mais n'est plus exposé. |
| `list_verticals` / `get_vertical` | Lister ou lire les Verticales JSON simples d’une organisation explicitement sélectionnée, avec leurs Personas et Offres liées. |
| `create_vertical` | Créer une Verticale tenant-scoped en `DRAFT`, sans activation ni sélection implicite. Son schéma JSON fermé porte les règles sectorielles et `commercialExperience` ; les métadonnées de preuve/source/provenance et les champs propres à l’Offre sont refusés. |
| `update_vertical` | Modifier le nom et/ou des clés JSON d’une Verticale après relecture. `expected_updated_at` est obligatoire ; une révision périmée est refusée avec `409` et impose une nouvelle lecture. Une Verticale `ACTIVE` repasse en `DRAFT`. |
| `change_vertical_status` | Passer une Verticale entre `DRAFT`, `ACTIVE` et `ARCHIVED` avec contrôle optimiste, readiness et protection des références. |
| `list_offers` / `get_offer` | Lister ou lire les Offres JSON simples tenant-scoped, leurs Verticales et leurs Lead Groups. |
| `create_offer` | Créer une Offre tenant-scoped en `DRAFT`, sans relation ni activation implicite. Son schéma JSON fermé est l'unique propriétaire des allégations, résultats démontrés et cas clients ; les Verticales référencent les identifiants de ces cas. |
| `update_offer` | Modifier le nom et/ou des clés JSON d’une Offre avec `expected_updated_at` obligatoire et refus `409` de toute écriture périmée. Une Offre `ACTIVE` repasse en `DRAFT`. |
| `change_offer_status` | Passer une Offre entre `DRAFT`, `ACTIVE` et `ARCHIVED` avec contrôle optimiste, readiness et protection des références. |
| `link_vertical_offer` / `unlink_vertical_offer` | Créer ou retirer la relation SQL tenant-scoped. Les `updatedAt` lus pour les deux objets sont obligatoires ; aucune relation cross-tenant ou dépendance Lead Group n’est contournée. |
| `preview_resolved_context` | Résoudre sans effet Company Brain + Verticale + Offre liée + Persona, ainsi que sender/CTA du Lead Group lorsqu'ils existent. Retourne le digest courant avec `dryRun:true`, `persisted:false`, `noSend:true`, `runtimeApplied:false` et `externalActivation:0`. |
| `list_canonical_relationships` | Lire les liens canoniques typés d'une organisation, dans les deux directions. |
| `create_canonical_relationship` | Créer un lien canonique tenant-scoped et idempotent entre deux identités fortes. |
| `update_canonical_relationship` | Modifier, restaurer ou tombstoner un lien via le ledger transactionnel partagé. |
| `tombstone_canonical_relationship` | Retirer réversiblement un lien sans supprimer son historique ni ses preuves. |
| `preview_canonical_relationship_migration` | Prévisualiser une migration de champs historiques, divergences et quarantaines comprises, sans mutation. |
| `apply_canonical_relationship_migration` | Appliquer exactement un plan prévisualisé et borné grâce à son digest. |
| `rollback_canonical_relationship_migration` | Tombstoner les liens créés par un plan de migration précis. |
| `configure_lead_group_relations` | Configurer, dans un tenant explicitement vérifié, la projection legacy relue par une migration canonique. |
| `preview_canonical_identity_backfill` | Prévisualiser le backfill d'identité tenant-scoped d'un groupe et de ses parents Company configurés. |
| `apply_canonical_identity_backfill` | Appliquer exactement un backfill d'identité relu par digest, sans outreach ni activation. |
| `update_company_brain_sections` | Modifier uniquement les sections globales encore actives (`identity`, `positioning`, `allowedVocabulary`, `prohibitedVocabulary`, `legalConstraints`). Les anciennes sections Offre/Verticale sont filtrées et refusées. |
| `publish_context_workspace` | Publier une révision prête du Company Brain après confirmation explicite (`confirm_publish: true`). Admin de l'organisation requis ; les raisons de non-readiness sont renvoyées par le serveur. |
| `list_persona_contracts` / `get_persona_contract` | Lister ou lire les contrats Persona canoniques v2 d’une organisation explicitement sélectionnée. `intelligence` ne contient que `painPoints`, `icpStrategy` et `cardAnalysisSections` ; `activeTools` n’appartient pas au contrat. |
| `create_persona_contract` | Créer un Persona tenant-scoped et lié à une Verticale depuis un contrat canonique v2 complet et strict. `tool_pack_id` est requis à la création et reste hors contrat. Toute clé inconnue est refusée. |
| `replace_persona_contract` / `patch_persona_contract` | Remplacer ou modifier un contrat canonique v2 avec verrou optimiste. `name` / `description` sont acceptés sur replace. `tool_pack_id` est optionnel : s’il est omis, le pack persisté est réutilisé. Une Persona ACTIVE mutée repasse en DRAFT. |
| `change_persona_status` | Passer un Persona entre `DRAFT`, `ACTIVE` et `ARCHIVED` avec le même verrou `updated_at`, readiness et protection des références. Après un replace/patch, rappeler ce tool vers `ACTIVE` pour que les groupes résolvent à nouveau la Persona. |
| `list_data_sources` | Lister toutes les sources de données configurées (par pays puis nom). |
| `create_data_source` | Créer une nouvelle source de données (admin uniquement). |
| `update_data_source` | Modifier une source de données existante (admin uniquement). |
| `delete_data_source` | Supprimer définitivement une source de données (admin uniquement). |
| `get_outreach_settings` | Récupérer la config outreach d'un lead group (positioning, sequence, rules, URLs, case studies). |
| `update_outreach_settings` | Update full-form (escape hatch) — remplace les blocs JSON entièrement. Préférer les tools granulaires ci-dessous. |
| `update_outreach_positioning` | Patch partiel du positioning (dream / fear / whyNow individuellement). |
| `update_outreach_sequence_slot` | Patch d'un seul slot de séquence (connexion, linkedin.one/two/three, email.one/two/three) sans toucher les autres. |
| `update_outreach_rules` | Patch du bloc rules : forbidden/priority (replace), format.* (deep-merge). |
| `update_outreach_case_study` | Ajouter / remplacer / supprimer un case study par index, sans re-envoyer la liste. |
| `update_outreach_urls` | Patch booking_url et/ou website_url uniquement. |
| `set_outreach_connection_request` | Toggle du flag connectionRequestEnabled (LinkedIn invite vs cold DM). |
| `trigger_outreach` | Générer un message via le runtime Write Outreach (dry-run par défaut). Le rédacteur libre peut fournir un `context_selection` complet Verticale–Offre–Persona ; aucun objet ou défaut n’est inféré. Avant une écriture, le runtime relit le digest et refuse toute persistance si le contexte a changé. |
| `append_fine_tuning` | Appendre du contenu à une section du Fine Tuning (nonNegotiableRules, pitfalls, structure, examples). Toujours en mode append — garantie contractuelle. |
| `set_fine_tuning_output_config` | Remplacer uniquement la configuration métier de sorties d’un Fine Tuning (`linkedinConnection`, `linkedinMessage`, `email`, activations et quantités). Préserve Markdown, langues et fallback ; ne génère, ne planifie, n’active ni n’envoie aucun outreach. |
| `pipeline_next_lead` | Sélectionner le prochain lead à traiter (score descendant, sans message). Exclusion des IDs déjà vus, limit 1-5. |

### `source-icp-prospects` : sourcing Personne rattachée à une Company

`add_leads` est un import générique de leads plats et ne garantit aucune
relation d’emploi : une Personne sourcée pour une entreprise doit toujours
passer par `upsert_person_leads_with_employment`, seul chemin d’écriture
Person admis pour le sourcing. Ce tool crée ou réutilise la Person Lead et la
Person, crée ou réutilise une relation active `EMPLOYED_BY` vers la cible
légale de la Company (Legal Entity ou Establishment compatible), maintient les
projections `parent_lead` / `child_leads` consommées par l’interface, écrit
uniquement les champs admis par le schéma du groupe Personne, ajoute
l’historique sans écraser l’existant, applique l’idempotence par entrée et
relit les objets avant de répondre. Chaque entrée est transactionnelle
(échec = rollback complet de l’entrée) ; un batch peut mêler succès et échecs
par entrée mais ne rend jamais un succès ambigu.

Chaîne obligatoire, dans l’ordre :

1. Vérifier l’identité canonique de la Company Lead. Si elle n’expose ni
   Account ni Legal Entity / Establishment compatible, la préparer d’abord
   avec `preview_canonical_identity_backfill` puis
   `apply_canonical_identity_backfill` en rejouant exactement le digest
   prévisualisé. Toute contradiction d’identité bloque proprement : jamais de
   rapprochement au nom approximatif.
2. Pour chaque candidat accepté, créer `create_person_employment_evidence`
   avec le tenant, la Company Lead exacte, l’identité Person, la provenance
   et une clé idempotente. Relire si nécessaire avec
   `get_person_employment_evidence`, puis transmettre exclusivement
   l’`evidence.id` retourné dans `evidence_ids` de
   `upsert_person_leads_with_employment`.
3. Appeler `upsert_person_leads_with_employment` avec les IDs exacts
   (`organization_id`, `person_group_id`, `company_lead_id` identiques au
   même tenant), les seuls champs admis par le schéma Personne,
   `card_history_append`, et une `idempotency_key` unique par entrée.
   Pour réparer une Person Lead orpheline exacte sans doublon, passer son
   `person_lead_id` : la réparation suit la même transaction et les mêmes
   contrôles, et préserve LinkedIn, sources, `card_history`,
   `qualification_status`, `hide_from_campaign` et les champs sans lien avec
   la relation.
4. Vérifier dans les deux sens avant de considérer la Personne rattachée :
   `get_lead` sur la Personne (parent résolvable) et
   `list_canonical_relationships` filtré sur la Person puis sur la cible
   légale (lien `EMPLOYED_BY` actif). Une relation ou projection manquante
   est un échec explicite, jamais un succès partiel.

Aucune mutation de sourcing ne qualifie le lead, n’enrichit les contacts
(email/téléphone), ne génère de message, n’inscrit en campagne et n’active
aucun canal externe : le readback porte `noSend: true` et
`externalActivation: 0`.
