Comment j’ai intégré OpenCode comme serveur d’agent headless pour propulser “Assist”, la fonctionnalité de chat IA de mon side project pour les enseignants de maternelle.

Je développe Prêt Pour l’École (PPE) sur mon temps libre, une web app qui aide les enseignants de maternelle/primaire (PS/MS) à préparer leurs cours, suivre leurs élèves, faire l’appel, et globalement survivre à la charge administrative du métier. L’une des fonctionnalités centrales est Assist, un assistant de chat capable de rédiger une séquence pédagogique, générer une mindmap, résumer un document, ou tout simplement répondre à une question pédagogique, en s’appuyant sur les vraies classes, élèves et emploi du temps de l’enseignant.

J’aurais pu brancher un endpoint de chat directement sur une API LLM et en rester là. À la place, j’ai mis OpenCode derrière, comme serveur d’agent autonome. Cet article explique pourquoi, et comment c’est réellement câblé.

Astuce

Tout le mérite revient à qui de droit : l’idée d’intégrer OpenCode comme backend d’agent headless plutôt que de construire une boucle d’agent from scratch vient de cet article sur trythis.app. Je l’ai lu, je me suis dit “attends, ça résout exactement mon problème”, et le backend d’Assist pour PPE en est le résultat. Allez lire l’original, il couvre le côté SDK/plugin bien plus en profondeur que je ne le fais ici.

Ce qu’OpenCode est réellement ici

OpenCode est normalement vendu comme un agent de codage en terminal — le CLI que tu lances dans un repo pour qu’il lise des fichiers, exécute des commandes et édite du code. Ce qui est moins évident dans le marketing, c’est que opencode serve n’est qu’un serveur HTTP exposant des sessions, des messages, et un flux d’événements en temps réel. Ce serveur se moque complètement de savoir si l’agent code ou non. Pointe-le vers un workspace sans aucun outil de code activé, donne-lui un AGENTS.md spécifique à ton domaine, et il devient un agent conversationnel générique avec tool-calling, gestion de sessions et streaming déjà tout construits.

C’est toute l’astuce : je n’utilise pas OpenCode pour écrire du code. J’utilise son serveur comme runtime d’agent gratuit — sessions, SSE, routage de providers, orchestration d’outils — pour un assistant pédagogique qui n’a rien à voir avec du développement logiciel.

Architecture

graph LR
    FE["Frontend<br/>React + TS"] -->|chat messages| BE["Backend<br/>Express + TS"]
    BE -->|create session, send message,<br/>subscribe /event| OC["opencode-service<br/>(opencode serve)"]
    OC -->|LLM calls| LLM["LiteLLM<br/>(Mistral)"]
    OC -->|MCP tool calls| BE
    BE -->|classes, students...| DB[("PostgreSQL")]

opencode-service est son propre pod dans le cluster, complètement séparé du backend principal. Le backend Express lui parle en HTTP simple (créer une session, envoyer un message, s’abonner à /event), et — c’est la partie que je préfère — OpenCode reparle au backend via MCP pour effectivement faire des choses : lire la liste d’une classe, récupérer un emploi du temps, chercher dans les documents uploadés par l’enseignant, créer un événement de calendrier. Le serveur d’agent ne touche jamais directement la base de données. Il ne sait qu’avoir une conversation et appeler des outils ; le backend reste la seule chose qui a une autorité réelle sur les données.

Le workspace : opencode.json et AGENTS.md

Tout ce dont OpenCode a besoin pour devenir “Assist” vit dans un répertoire workspace qui est intégré dans l’image Docker du service :

1FROM node:20-slim
2WORKDIR /app
3RUN --mount=type=cache,target=/root/.npm npm install -g opencode-ai
4COPY workspace/ /workspace/
5WORKDIR /workspace
6EXPOSE 4096
7CMD ["sh", "-c", "... inject secrets into opencode.json ... && opencode serve --port 4096 --hostname 0.0.0.0"]

opencode.json est l’endroit où j’ai désactivé tout ce qui fait d’OpenCode un agent de codage :

 1{
 2  "model": "mistral/mistral-small-latest",
 3  "provider": {
 4    "mistral": {
 5      "npm": "@ai-sdk/openai-compatible",
 6      "options": { "baseURL": "https://litellm.internal/v1" },
 7      "models": {
 8        "mistral/mistral-small-latest": { "name": "Mistral Small", "description": "Rapide et économique" },
 9        "mistral/mistral-medium-latest": { "name": "Mistral Medium", "description": "Équilibre performance et coût" },
10        "mistral/mistral-large-latest": { "name": "Mistral Large", "description": "Raisonnement complexe" }
11      }
12    }
13  },
14  "tools": {
15    "file_read": false,
16    "file_write": false,
17    "file_edit": false,
18    "bash": false,
19    "glob": false,
20    "grep": false,
21    "lsp": false
22  },
23  "permission": "allow",
24  "mcp": {
25    "ppe-tools": { "type": "remote", "url": "__PPE_BACKEND_MCP_URL__", "headers": { "X-Backend-Shared-Secret": "__ASSIST_TOOL_SECRET__" } }
26  }
27}

Pas d’accès au système de fichiers, pas de shell, pas de LSP. Les seuls outils dont il dispose sont ceux que je lui ai explicitement donnés via un serveur MCP distant pointant vers mon propre backend (plus de détails ci-dessous). provider pointe vers LiteLLM comme endpoint compatible OpenAI, donc remplacer Mistral par autre chose plus tard est un simple changement de config, pas une réécriture.

Le model de premier niveau n’est que le fallback. Chaque requête choisit le modèle réel selon la préférence propre de l’enseignant, stockée en base et transmise à sendMessage/streamMessage :

1const model = userModel?.preferred_model || 'mistral-small-latest';

Ainsi un enseignant qui veut un meilleur raisonnement pour une séquence délicate peut basculer sur Mistral Large depuis l’UI, tool-calling inclus — Small n’est que ce que tu obtiens par défaut tant que tu n’as pas touché ce réglage.

AGENTS.md est essentiellement le system prompt :

 1# Assist — Assistant Pédagogique Intelligent
 2
 3## Rôle
 4Tu es "Assist", un assistant pédagogique expert en éducation maternelle et
 5primaire (cycles 1-3, école française). Tu aides les enseignants à préparer
 6leurs cours, créer des séquences pédagogiques, et améliorer leurs contenus.
 7
 8## Règles
 9- Toujours répondre en français
10- Utiliser les skills quand l'utilisateur demande explicitement une action spécifique
11- Pour les mindmaps, réponds UNIQUEMENT avec un objet JSON tree...

C’est le fichier qui définit réellement la personnalité du produit et ses contrats de sortie (JSON strict pour les mindmaps, schéma spécifique pour les séquences pédagogiques). Changer ce qu’Assist est consiste à éditer un fichier markdown, pas à déployer une nouvelle logique d’agent.

Skills : des playbooks réutilisables plutôt qu’un prompt géant

Plutôt que d’entasser chaque capacité dans AGENTS.md, chacune vit dans sa propre skill sous .opencode/skills/ :

1.opencode/skills/
2├── improve/       corrige un texte pédagogique
3├── lesson-plan/   génère une fiche de séquence structurée
4├── mindmap/       génère une mindmap au format JSON tree
5├── summary/       résume un contenu pédagogique
6└── translate/     traduit entre le français et une autre langue

Chaque SKILL.md est une petite spec autonome — nom, description, pattern d’usage, format de sortie attendu :

 1---
 2name: lesson-plan
 3description: Générer une fiche de séquence pédagogique structurée
 4---
 5## Utilisation
 6/séquence <sujet> [niveau] [durée]
 7
 8## Format de sortie
 9- Titre de la séquence
10- Domaine d'apprentissage
11- Objectifs pédagogiques
12- Déroulement (blocs de 30-45 min...)
13- Évaluation

Le backend active les instructions de la skill pertinente message par message, selon ce que l’utilisateur fait dans l’UI, au lieu de demander au modèle d’inférer l’intention à partir d’un prompt monstrueux à chaque fois. Ajouter une nouvelle capacité à Assist se résume à : écrire un SKILL.md, ajouter éventuellement un outil MCP correspondant, terminé.

Donner à l’agent de vrais outils, en toute sécurité

C’est la partie qui a demandé le plus de réflexion. L’agent doit agir sur de vraies données d’enseignant — lister une classe, consulter un emploi du temps, créer un événement de calendrier — mais il tourne dans un pod complètement séparé, sans aucune idée de qui lui parle réellement. Je ne voulais faire confiance à rien de ce que le modèle raconte sur “quel est cet utilisateur” (les modèles inventeront allègrement un ID utilisateur si tu les laisses faire).

Le backend expose donc un serveur MCP interne (/api/internal/mcp), construit sur le transport Streamable HTTP du SDK plutôt que l’ancien transport HTTP+SSE :

1import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';

Ça vaut le coup d’être précisé puisque ça côtoie le flux SSE décrit plus bas et qu’il est facile de confondre les deux : l’endpoint /event d’OpenCode est en SSE parce que c’est ce qu’expose opencode serve — ce n’est pas un choix de ma part, je ne fais que le consommer. Le serveur MCP, en revanche, c’est du code que je maîtrise, et Streamable HTTP est le transport privilégié par la spec MCP depuis qu’il a remplacé HTTP+SSE dans la révision du 2025-03-26 — c’est donc celui que j’ai choisi.

Chaque message envoyé par l’utilisateur porte un token signé en HMAC, à courte durée de vie, liant la conversation à l’utilisateur réellement connecté :

1export function signToolToken(userId: number, conversationId: string, ttlSeconds = 1800): string {
2  const expiresAt = Date.now() + ttlSeconds * 1000;
3  const payload = JSON.stringify({ userId, conversationId, expiresAt });
4  const payloadB64 = b64url(payload);
5  const sig = createHmac('sha256', ASSIST_TOOL_SECRET).update(payloadB64).digest();
6  return `${payloadB64}.${b64url(sig)}`;
7}

Ce token est injecté directement dans le system prompt du message, accompagné d’une instruction explicite de le retransmettre tel quel :

1[OUTILS PPE]
2Tu as accès à des outils pour consulter et agir sur PPE (classes, élèves,
3emploi du temps, calendrier...). [...]
4Tous ces outils, SAUF search_web, exigent le paramètre "session_token".
5Passe TOUJOURS exactement cette valeur, sans la modifier ni l'inventer : ${toolToken}

Chaque appel d’outil lié à un utilisateur vérifie d’abord ce token et en extrait le vrai userId — le modèle peut halluciner tant qu’il veut, il ne peut pas forger une signature valide :

1function requireUser(sessionToken: string): { userId: number } | { error: ToolResult } {
2  const verified = verifyToolToken(sessionToken);
3  if (!verified) {
4    return { error: errorResult('session_token invalide ou expiré...') };
5  }
6  return { userId: verified.userId };
7}

Il y a deux frontières de confiance distinctes empilées ici, et je pense que cette séparation compte : un secret partagé (X-Backend-Shared-Secret) prouve que cette requête MCP provient bien de notre propre instance OpenCode et de rien d’autre ; le token par conversation prouve que cet appel d’outil précis est effectué pour le compte de cet enseignant précis. Perdre le secret partagé ne permet à personne d’usurper un utilisateur, et un token d’outil qui fuite expire en 30 minutes et est cantonné à une seule conversation.

Avec ça en place, l’agent dispose de 14 outils réels enregistrés sur un serveur MCP :

1search_web            list_classes           get_calendar_events
2create_lesson_plan    get_class_detail       create_calendar_event
3create_mindmap        list_students           list_lesson_plans
4search_documents      get_timetable          create_presentation
5get_student_report    get_class_report

La plupart sont des lectures seules verrouillées par checkClassAccess() (un enseignant ne peut pas interroger la classe d’un autre enseignant). L’autre moitié est la partie intéressante.

Un seul agent, tous les documents dont un enseignant a besoin

C’est vraiment tout l’intérêt de ce montage : Assist n’est pas un chatbot greffé sur PPE qui répond accessoirement à des questions sur l’enseignement — c’est ce qui produit réellement les documents qu’un enseignant construirait sinon à la main sur cinq écrans différents. Demande une séquence pédagogique, une mindmap sur le cycle de l’eau, un jeu de slides de présentation, ou un événement de calendrier pour une sortie scolaire, et la même conversation appelle create_lesson_plan, create_mindmap, create_presentation ou create_calendar_event — chacun renvoie du structuredContent, pas de la prose :

 1server.registerTool(
 2  'create_mindmap',
 3  {
 4    description: 'Produit une carte mentale structurée (arbre). N\'écrit rien en base : '
 5      + 'le résultat est affiché directement dans la conversation.',
 6    inputSchema: mindmapSchema.shape,
 7    outputSchema: mindmapSchema.shape,
 8  },
 9  async (input) => jsonResult(input),
10);

Le backend ne fait que retransmettre le structuredContent au frontend — aucune tentative de le rendre en texte. Le frontend connaît le schéma de chaque outil et affiche le vrai widget PPE : un appel à l’outil mindmap devient un canvas Excalidraw en direct dans le chat, une séquence pédagogique devient la même carte structurée que celle que tu verrais sur la page Séquences, une proposition de calendrier apparaît comme une vraie carte d’événement que l’enseignant peut déposer sur son calendrier. Personne ne demande au modèle de bricoler une UI riche à la main, et personne ne fait du screen-scraping de markdown pour reconstruire des données structurées — elles sortent structurées parce que l’outputSchema de l’outil l’a imposé.

Et chacun d’eux s’arrête un cran avant d’écrire quoi que ce soit : create_lesson_plan, create_mindmap, create_calendar_event, create_presentation sont tous explicitement “N’écrit RIEN en base” dans leur propre description — ils proposent, l’enseignant relit le résultat affiché et clique pour confirmer, et c’est seulement alors qu’un appel REST authentifié classique (le même que celui que l’UI ferait si l’enseignant avait tout construit à la main) le persiste. L’agent couvre ainsi toute la surface de ce qu’un enseignant produit en une semaine, sans jamais détenir l’autorité d’écriture sur la base de données elle-même.

Le renvoyer en streaming vers l’UI de chat

Le backend ne fait jamais de polling pour obtenir une réponse. Il ouvre une connexion persistante unique vers le flux SSE /event d’OpenCode et redistribue par ID de session à qui écoute actuellement :

1const res = await fetch(`${OPENCODE_URL}/event`, { headers: authHeaders(), signal });
2const reader = res.body?.getReader();
3// ...
4for (const line of lines) {
5  if (line.startsWith('data: ')) {
6    const event = JSON.parse(line.slice(6));
7    this.dispatch(event);
8  }
9}

Les événements message.part.updated portent les deltas de texte au fur et à mesure que le modèle génère sa réponse, session.status bascule entre busy/idle, session.error porte les erreurs du provider. Chacun de ces événements est retransmis à la réponse Express sous forme de son propre flux SSE vers le navigateur, si bien que l’enseignant voit la réponse s’écrire en direct, exactement comme le ferait le CLI OpenCode lui-même — je n’ai qu’à m’épargner la construction de toute cette plomberie de streaming.

Déploiement

opencode-service tourne comme son propre Deployment Kubernetes, interne uniquement (pas d’ingress), joignable depuis le backend par son nom de service :

1env:
2  - name: LITELLM_URL
3    value: "https://litellm.internal"
4  - name: PPE_BACKEND_MCP_URL
5    value: "http://{{ include \"ppe.fullname\" . }}:{{ .Values.service.port }}/api/internal/mcp"
6  - name: ASSIST_TOOL_SECRET
7    valueFrom: { secretKeyRef: { name: opencode-secrets, key: assist-tool-secret } }

Le même secret des deux côtés, injecté au démarrage du conteneur dans opencode.json (le script d’entrypoint patche la clé API et l’URL/secret MCP dans le fichier de config avant que opencode serve ne démarre). Le faire scaler consiste juste à augmenter replicaCount — les sessions vivent dans le stockage propre d’OpenCode, pas dans le pod, donc il n’y a rien de “sticky” quant à quel replica gère quelle conversation, en dehors du cache runtime que garde le backend pour éviter de recréer une session à chaque message.

Ce que ça m’a apporté

Je n’ai pas écrit une seule ligne de code de “boucle d’agent” — pas de parseur manuel de tool-calling, pas de logique de réassemblage de streaming, pas d’abstraction de provider. Tout ça, c’est le boulot d’OpenCode, et c’est un boulot sincèrement bien fait. Ce que j’ai écrit, c’est la partie réellement spécifique à mon produit : quels outils existent, ce qu’ils ont le droit de toucher, et comment prouver qui pose la question. Ça m’a semblé être le bon endroit où investir l’effort.

L’écran d’accueil d’Assist, avec les suggestions générées à partir des skills configurées

Si tu construis quelque chose de similaire, va d’abord lire l’article de trythis.app — c’est ce qui m’a mis sur la voie au départ, et il va plus loin sur le côté plugin/SDK dont je n’ai pas eu besoin pour ce projet.

comments powered by Disqus