écrits/tutorial/2026/06
Tutorial30 juin 2026·30 min

Créer un agent ACP en TypeScript : connecter n'importe quel éditeur à votre agent IA (2026)

Apprenez à construire un agent de codage IA qui parle l'Agent Client Protocol (ACP) en TypeScript. Implémentez initialize, les sessions, le streaming et les demandes de permission, puis branchez-le sur Zed, Neovim ou tout client ACP.

Le LSP l'a fait pour les serveurs de langage. L'ACP le fait pour les agents IA. L'Agent Client Protocol est un standard JSON-RPC ouvert qui permet à n'importe quel éditeur de dialoguer avec n'importe quel agent. Dans ce tutoriel, vous construirez votre propre agent compatible ACP en TypeScript et le connecterez à Zed.

Ce que vous allez apprendre

À la fin de ce tutoriel, vous saurez :

  • Comprendre l'architecture de l'Agent Client Protocol (ACP) et sa raison d'être
  • Mettre en place un projet d'agent ACP en TypeScript de zéro
  • Implémenter l'interface Agent : initialize, newSession et prompt
  • Diffuser la sortie en temps réel vers l'éditeur avec les session updates
  • Demander une permission à l'utilisateur avant d'exécuter un outil
  • Brancher votre agent dans Zed et le tester de bout en bout

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20+ installé (node --version)
  • Des connaissances en TypeScript (interfaces, async/await, modules ES)
  • Un éditeur de code — Zed est utilisé pour le test final, mais VS Code ou Cursor conviennent pour écrire le code
  • Une compréhension de base de JSON-RPC et des flux d'entrée/sortie standard
  • En option, une clé API Anthropic si vous voulez que l'agent appelle un vrai modèle

Qu'est-ce que l'Agent Client Protocol ?

L'Agent Client Protocol (ACP) est un standard ouvert qui définit comment un éditeur de code (le client) communique avec un agent de codage IA (l'agent). Il repose sur JSON-RPC 2.0 et fonctionne via stdio — l'agent est un sous-processus, et les messages circulent sous forme de JSON délimité par des sauts de ligne sur l'entrée et la sortie standard.

Voyez-le comme le Language Server Protocol (LSP) pour les agents IA. Avant le LSP, chaque éditeur avait besoin d'une intégration sur mesure pour chaque langage. Avant l'ACP, chaque éditeur avait besoin d'une intégration sur mesure pour chaque agent. L'ACP transforme un problème d'intégration N×M en N+M : écrivez votre agent une fois, et il fonctionne dans Zed, Neovim, JetBrains, Emacs et tout autre client ACP.

Où se situe l'ACP

L'ACP est complémentaire de deux autres standards que vous connaissez peut-être :

  • MCP (Model Context Protocol) connecte les agents aux outils et aux données.
  • A2A (Agent-to-Agent) connecte les agents à d'autres agents.
  • ACP connecte les agents à l'éditeur et à l'humain dans la boucle.

Les trois méthodes principales

Un agent ACP doit répondre au minimum à trois requêtes :

1. initialize   →  négocier la version du protocole + les capacités
2. session/new  →  créer une session de conversation
3. session/prompt →  traiter un tour utilisateur, diffuser, renvoyer un stop reason

Pendant un tour de prompt, l'agent envoie des notifications session/update vers le client (texte en streaming, réflexions, appels d'outils) et peut envoyer session/request_permission avant toute action sensible.


Étape 1 : Mise en place du projet

Créez un nouveau projet et installez le SDK TypeScript officiel.

mkdir acp-hello-agent && cd acp-hello-agent
npm init -y
npm install @zed-industries/agent-client-protocol
npm install -D typescript tsx @types/node

Créez un tsconfig.json configuré pour les modules ES modernes :

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist"
  },
  "include": ["src"]
}

Définissez "type": "module" dans package.json pour que Node traite vos fichiers comme des modules ES :

{
  "name": "acp-hello-agent",
  "type": "module",
  "bin": { "acp-hello-agent": "dist/agent.js" },
  "scripts": {
    "dev": "tsx src/agent.ts",
    "build": "tsc"
  }
}

Pourquoi stdio est crucial. Les agents ACP communiquent via l'entrée et la sortie standard, jamais via le réseau par défaut. Cela signifie que console.log est interdit dans votre code d'agent — tout ce que vous affichez sur stdout devient un message de protocole malformé. Utilisez plutôt console.error (stderr) pour le débogage.


Étape 2 : Comprendre l'interface Agent

Le SDK expose une interface Agent. Voici la forme que vous implémenterez (simplifiée) :

interface Agent {
  initialize(params: InitializeRequest): Promise<InitializeResponse>;
  newSession(params: NewSessionRequest): Promise<NewSessionResponse>;
  prompt(params: PromptRequest): Promise<PromptResponse>;
  cancel(params: CancelNotification): Promise<void>;
  // optionnel : loadSession, authenticate, setSessionMode, ...
}

Vous connectez votre implémentation à l'éditeur via AgentSideConnection et un flux créé avec ndJsonStream (JSON délimité par sauts de ligne). L'objet de connexion sert aussi à renvoyer les mises à jour vers le client.


Étape 3 : Implémenter initialize et newSession

Créez src/agent.ts. Commencez par les méthodes de poignée de main. L'appel initialize négocie la version du protocole et annonce ce que votre agent sait faire.

import {
  Agent,
  AgentSideConnection,
  ndJsonStream,
  PROTOCOL_VERSION,
  type InitializeRequest,
  type InitializeResponse,
  type NewSessionRequest,
  type NewSessionResponse,
  type PromptRequest,
  type PromptResponse,
  type CancelNotification,
} from "@zed-industries/agent-client-protocol";
import { Readable, Writable } from "node:stream";
 
class HelloAgent implements Agent {
  // La connexion permet de pousser les session updates vers le client.
  constructor(private conn: AgentSideConnection) {}
 
  // On suit les sessions actives pour pouvoir les annuler plus tard.
  private sessions = new Map<string, AbortController>();
 
  async initialize(params: InitializeRequest): Promise<InitializeResponse> {
    return {
      protocolVersion: PROTOCOL_VERSION,
      agentCapabilities: {
        // On peut recharger d'anciennes sessions en mémoire.
        loadSession: false,
        // On déclare les types de contenu de prompt acceptés.
        promptCapabilities: { image: false, audio: false, embeddedContext: true },
      },
      authMethods: [], // Aucune auth nécessaire pour cette démo.
    };
  }
 
  async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
    // params.cwd contient la racine du workspace ouvert par l'éditeur.
    const sessionId = `sess_${this.sessions.size + 1}`;
    this.sessions.set(sessionId, new AbortController());
    return { sessionId };
  }
 
  async cancel(params: CancelNotification): Promise<void> {
    this.sessions.get(params.sessionId)?.abort();
  }
 
  // prompt() vient ensuite.
  async prompt(params: PromptRequest): Promise<PromptResponse> {
    return { stopReason: "end_turn" };
  }
}

Quelques points à noter :

  • PROTOCOL_VERSION est exporté par le SDK pour que votre agent annonce toujours la version sur laquelle il a été construit.
  • agentCapabilities indique à l'éditeur quoi activer dans son interface — par exemple, proposer ou non les pièces jointes image.
  • On stocke un AbortController par session afin qu'une notification cancel puisse interrompre un tour en cours.

Étape 4 : Diffuser la sortie avec les session updates

Le cœur d'un agent est la méthode prompt. L'éditeur envoie le message de l'utilisateur, et votre rôle est de :

  1. Lire le prompt utilisateur depuis params.prompt (un tableau de blocs de contenu).
  2. Diffuser la réponse via des notifications session/update.
  3. Renvoyer un stop reason quand le tour est terminé.

Remplacez la méthode prompt provisoire par un écho en streaming qui tape la réponse mot par mot :

async prompt(params: PromptRequest): Promise<PromptResponse> {
  const { sessionId } = params;
  const controller = this.sessions.get(sessionId);
 
  // Extraire le texte de l'utilisateur depuis les blocs de contenu.
  const userText = params.prompt
    .filter((block) => block.type === "text")
    .map((block) => block.text)
    .join(" ");
 
  const reply = `Vous avez dit : "${userText}". Voici une réponse diffusée.`;
 
  // Diffuser la réponse un fragment à la fois.
  for (const word of reply.split(" ")) {
    if (controller?.signal.aborted) {
      return { stopReason: "cancelled" };
    }
 
    await this.conn.sessionUpdate({
      sessionId,
      update: {
        sessionUpdate: "agent_message_chunk",
        content: { type: "text", text: word + " " },
      },
    });
 
    // Simuler la latence des tokens pour rendre le streaming visible.
    await new Promise((r) => setTimeout(r, 60));
  }
 
  return { stopReason: "end_turn" };
}

La charge utile sessionUpdate prend en charge plusieurs types de mises à jour. Les plus courants sont :

Valeur sessionUpdateRôle
agent_message_chunkTexte visible de l'assistant
agent_thought_chunkRaisonnement affiché dans un bloc repliable
tool_callAnnonce d'un outil que l'agent va exécuter
tool_call_updateProgression ou résultat d'un outil
planUn plan en plusieurs étapes que l'agent compte suivre

Les stop reasons valides sont end_turn, max_tokens, max_turn_requests, refusal et cancelled.


Étape 5 : Connexion via stdio

Reliez maintenant l'agent au transport. L'ACP fonctionne avec du JSON délimité par sauts de ligne sur stdio ; vous convertissez donc process.stdout et process.stdin de Node en flux Web et les passez à ndJsonStream.

Ajoutez ce point d'entrée au bas de src/agent.ts :

function main() {
  // L'ACP écrit sur stdout et lit sur stdin.
  // Convertir les flux Node en flux Web attendus par le SDK.
  const input = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
  const output = Writable.toWeb(process.stdout) as WritableStream<Uint8Array>;
 
  const stream = ndJsonStream(output, input);
 
  // La factory reçoit la connexion et renvoie notre Agent.
  new AgentSideConnection((conn) => new HelloAgent(conn), stream);
 
  // Garder le processus en vie ; la connexion pilote tout le reste.
  process.stdin.resume();
}
 
main();

Le constructeur AgentSideConnection prend une fonction factory. Le SDK vous fournit l'objet de connexion, et vous renvoyez votre implémentation Agent. Cette inversion permet à votre agent de pousser des mises à jour via conn tout en répondant aux requêtes entrantes.

Vérifiez que le processus démarre sans planter :

npm run dev
# Il devrait rester en attente de stdin — c'est normal. Appuyez sur Ctrl+C.

Étape 6 : Demander la permission avant d'agir

Les vrais agents modifient des fichiers et exécutent des commandes. L'ACP intègre un flux pour cela : avant une action sensible, envoyez session/request_permission et laissez l'éditeur présenter un choix à l'utilisateur.

Voici un assistant que vous pouvez appeler dans prompt, par exemple avant d'écrire un fichier :

private async confirm(
  sessionId: string,
  title: string,
): Promise<boolean> {
  const result = await this.conn.requestPermission({
    sessionId,
    toolCall: {
      toolCallId: `call_${Date.now()}`,
      title,
      kind: "edit",
      status: "pending",
    },
    options: [
      { optionId: "allow", name: "Autoriser", kind: "allow_once" },
      { optionId: "reject", name: "Refuser", kind: "reject_once" },
    ],
  });
 
  // Le client renvoie l'option choisie par l'utilisateur.
  return result.outcome?.outcome === "selected" &&
    result.outcome.optionId === "allow";
}

Comme on utilise un horodatage plutôt qu'une valeur aléatoire pour l'identifiant, l'exemple reste déterministe et facile à journaliser. En production, générez un identifiant unique stable par appel d'outil. Le champ kind — parmi read, edit, delete, execute, fetch et d'autres — permet à l'éditeur d'afficher une icône et un avertissement adaptés.


Étape 7 : Brancher un vrai modèle (optionnel)

Pour rendre l'agent utile, remplacez la logique d'écho par un appel à un modèle de langage. Installez le SDK Anthropic et diffusez les tokens directement dans sessionUpdate :

npm install @anthropic-ai/sdk
import Anthropic from "@anthropic-ai/sdk";
 
const client = new Anthropic(); // lit ANTHROPIC_API_KEY depuis l'environnement
 
async function streamModel(
  conn: AgentSideConnection,
  sessionId: string,
  userText: string,
) {
  const stream = client.messages.stream({
    model: "claude-opus-4-8",
    max_tokens: 1024,
    messages: [{ role: "user", content: userText }],
  });
 
  stream.on("text", async (delta) => {
    await conn.sessionUpdate({
      sessionId,
      update: {
        sessionUpdate: "agent_message_chunk",
        content: { type: "text", text: delta },
      },
    });
  });
 
  await stream.finalMessage();
}

Appelez streamModel depuis prompt, puis renvoyez { stopReason: "end_turn" } une fois le flux terminé. Ne codez jamais la clé API en dur — conservez-la dans la variable d'environnement ANTHROPIC_API_KEY.


Étape 8 : Connexion à Zed

Compilez votre agent, puis enregistrez-le dans le settings.json de Zed sous la clé agent_servers :

npm run build
{
  "agent_servers": {
    "Hello Agent": {
      "command": "node",
      "args": ["/chemin/absolu/vers/acp-hello-agent/dist/agent.js"],
      "env": {
        "ANTHROPIC_API_KEY": "sk-ant-..."
      }
    }
  }
}

Ouvrez le panneau d'agents dans Zed, choisissez Hello Agent dans la liste et envoyez un message. Vous devriez voir votre réponse diffusée apparaître token par token — la preuve que votre agent personnalisé parle correctement l'ACP.


Tester votre implémentation

Vérifiez chaque couche avant de crier victoire :

  • Le processus démarre : npm run dev reste en attente sur stdin sans erreur.
  • Poignée de main : Zed affiche l'agent dans le sélecteur (initialize a réussi).
  • Streaming : les réponses apparaissent progressivement, pas d'un seul bloc.
  • Annulation : arrêter un tour en cours renvoie cancelled et stoppe la sortie.
  • Permission : les actions sensibles déclenchent une invite dans l'interface de l'éditeur.

Pour un test au niveau du transport sans éditeur, envoyez un message JSON-RPC fabriqué à la main dans le processus :

echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}' | npm run dev

Vous devriez voir une seule ligne JSON revenir avec les capacités de votre agent.


Dépannage

L'agent n'apparaît jamais dans Zed. Vérifiez que le chemin dans args est absolu et que npm run build a bien produit dist/agent.js. Consultez le journal de Zed (zed: open log) pour les erreurs de lancement.

Messages brouillés ou dupliqués. Vous avez presque sûrement appelé console.log quelque part. Chaque écriture parasite sur stdout corrompt le flux JSON-RPC. Basculez tout le débogage sur console.error.

Décalage de version initialize. Renvoyez toujours PROTOCOL_VERSION du SDK plutôt qu'un nombre codé en dur, pour que l'agent et le client restent synchronisés à mesure que le protocole évolue.

Le flux ne se termine jamais. Assurez-vous que chaque chemin de prompt renvoie un PromptResponse avec un stop reason — y compris les branches d'annulation et d'erreur.


Étapes suivantes

  • Ajoutez des appels d'outils pour que l'agent lise et écrive des fichiers via fs/read_text_file et fs/write_text_file.
  • Émettez une mise à jour plan pour que les utilisateurs voient les intentions multi-étapes de l'agent avant qu'il n'agisse.
  • Publiez votre agent dans le Registre ACP pour que d'autres développeurs l'installent dans tous les clients.
  • Explorez les tutoriels connexes : Créer un serveur MCP en TypeScript, Claude Agent SDK pour TypeScript et OpenAI Agents SDK en production.

Conclusion

Vous avez construit un agent ACP fonctionnel à partir de rien : une poignée de main, une session, une sortie en streaming, un flux de permission et un vrai modèle derrière — le tout sur du simple stdio. Parce qu'il parle l'Agent Client Protocol, le même binaire fonctionne aujourd'hui dans Zed et dans tout futur client ACP sans la moindre modification. Cette portabilité est tout l'enjeu : dans un paysage 2026 où l'accès aux modèles fluctue selon les tarifs et les contrôles à l'export, un agent qui n'est pas verrouillé à un éditeur — et un éditeur qui n'est pas verrouillé à un agent — est un avantage stratégique. Écrivez une fois, connectez partout.