· 7 min de lecture
Créer un clone de Taplio : un ghostwriter LinkedIn sur Claude et Hapi
Construisez votre propre ghostwriter LinkedIn : Claude propose trois posts dans votre voix, vous en choisissez un, vous le retouchez, Hapi le publie ou le planifie. Environ 120 lignes de Node, plus une version sans code via MCP.
La boucle de Taplio est simple : vous donnez un sujet, vous obtenez des posts LinkedIn qui sonnent comme vous, vous planifiez le meilleur. Deux briques d’infrastructure font tenir ça :
- Un modèle qui écrit bien dans votre voix. C’est Claude.
- Un moyen fiable de publier sur LinkedIn. OAuth, rafraîchissement des tokens, la limite de 3 000 caractères, la planification et les relances. C’est Hapi.
Vous écrivez ce qu’il y a entre les deux : environ 120 lignes. À la fin, vous aurez :
- Une zone où vous tapez un sujet ou des notes brutes
- Trois brouillons de Claude, chacun avec un angle différent, écrits dans votre voix (apprise de posts que vous avez déjà publiés)
- La possibilité de retoucher un brouillon, puis de cliquer sur Post now ou Schedule vers LinkedIn (et vers X ou Bluesky, si vous voulez)
Il y a aussi une version sans code à la fin, si vous voulez simplement que Claude publie pour vous.
1. Mise en place
- Démarrez un essai Hapi (15 jours pour 5 $) et connectez LinkedIn dans Accounts (OAuth en un clic). Créez une clé API.
- Récupérez une clé API Claude sur platform.claude.com.
- Créez un dossier et installez le SDK d’Anthropic :
mkdir taplio-clone && cd taplio-clone
npm init -y && npm install @anthropic-ai/sdk- Apprenez-lui votre voix : collez 3 à 10 de vos anciens posts LinkedIn dans
voice.txt, séparés par une ligne contenant---.
2. Rédiger dans votre voix
L’astuce est dans le prompt système : vos vrais posts servent d’exemples, et Claude renvoie exactement trois brouillons en JSON. L’application n’a donc pas à deviner où l’un finit et l’autre commence. Les sorties structurées garantissent la forme :
const DRAFTS = {
type: "object",
properties: {
posts: {
type: "array",
items: {
type: "object",
properties: { angle: { type: "string" }, text: { type: "string" } },
required: ["angle", "text"],
additionalProperties: false,
},
},
},
required: ["posts"],
additionalProperties: false,
};La requête utilise Claude Opus 5.5 avec fallbacks: "default" : si un classificateur de sécurité refuse une demande, l’API la réessaie sur le modèle de repli recommandé par Anthropic, au lieu d’échouer.
3. L’application entière
// taplio-clone.mjs: an AI LinkedIn ghostwriter on Claude + Hapi.
// Run: ANTHROPIC_API_KEY=... HAPI_API_KEY=hapi_live_... APP_PASSWORD=pick-one node taplio-clone.mjs
import fs from "node:fs";
import http from "node:http";
import Anthropic from "@anthropic-ai/sdk";
const HAPI = process.env.HAPI_API_BASE ?? "https://hapi.tools/v1";
const { HAPI_API_KEY, APP_PASSWORD, PORT = 3000 } = process.env;
const VOICE = fs.readFileSync(process.env.VOICE_FILE ?? "voice.txt", "utf8");
const claude = new Anthropic(); // reads ANTHROPIC_API_KEY
const DRAFTS = {
type: "object",
properties: {
posts: {
type: "array",
items: {
type: "object",
properties: { angle: { type: "string" }, text: { type: "string" } },
required: ["angle", "text"],
additionalProperties: false,
},
},
},
required: ["posts"],
additionalProperties: false,
};
async function draft(notes) {
const response = await claude.beta.messages.create({
model: "claude-opus-5-5",
max_tokens: 16000,
betas: ["server-side-fallback-2026-07-01"],
fallbacks: "default",
output_config: { effort: "medium", format: { type: "json_schema", schema: DRAFTS } },
system:
"You ghostwrite LinkedIn posts for one person. Match the voice, rhythm, formatting and vocabulary of their past posts below. " +
"Write exactly 3 drafts, each with a different angle (for example: a story, a lesson, a contrarian take). " +
"Each draft must be under 1,300 characters, open with a strong first line, and use no hashtags unless their past posts do.\n\n" +
`<past_posts>\n${VOICE}\n</past_posts>`,
messages: [{ role: "user", content: `Topic or notes for today's post:\n\n${notes}` }],
});
if (response.stop_reason === "refusal") throw new Error("Claude declined this topic. Try rephrasing it.");
const text = response.content.find((block) => block.type === "text")?.text;
return JSON.parse(text).posts;
}
async function hapi(path, options = {}) {
const res = await fetch(HAPI + path, {
...options,
headers: { Authorization: `Bearer ${HAPI_API_KEY}`, "Content-Type": "application/json" },
});
const body = res.status === 204 ? null : await res.json();
if (!res.ok) throw new Error(body?.error?.message ?? `Hapi returned ${res.status}`);
return body;
}
const esc = (s = "") => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
async function page({ message = "", notes = "", drafts = [] } = {}) {
const { data: accounts } = await hapi("/accounts");
const boxes = accounts
.map((a) => `<label><input type="checkbox" name="accountId" value="${esc(a.id)}" ${a.platform === "linkedin" ? "checked" : ""}> ${esc(a.platform)} · ${esc(a.username ?? a.displayName)}</label>`)
.join(" ");
return `<!doctype html><meta charset="utf-8"><title>Ghostwriter</title>
<style>body{font:16px system-ui;max-width:680px;margin:40px auto;padding:0 16px}textarea{width:100%;height:120px}.d textarea{height:220px}.d{border:1px solid #ddd;border-radius:12px;padding:12px 16px;margin:16px 0}</style>
<h1>Ghostwriter</h1>${message ? `<p><b>${esc(message)}</b></p>` : ""}
<form method="post" action="/draft"><textarea name="notes" required placeholder="What's on your mind? Rough notes are fine.">${esc(notes)}</textarea><button>Draft 3 posts</button></form>
${drafts.map((d) => `<form class="d" method="post" action="/publish">
<p><b>${esc(d.angle)}</b></p><textarea name="content">${esc(d.text)}</textarea>
<p>${boxes}</p>
<p><input type="datetime-local" name="at"> <input type="hidden" name="scheduledAt">
<button name="action" value="schedule">Schedule</button> <button name="action" value="now">Post now</button></p></form>`).join("")}
<script>
// Convert the picked local time to an exact UTC timestamp before sending.
for (const f of document.querySelectorAll("form.d")) f.addEventListener("submit", () => {
f.scheduledAt.value = f.at.value ? new Date(f.at.value).toISOString() : "";
});
</script>`;
}
function readBody(req) {
return new Promise((resolve) => {
let data = "";
req.on("data", (chunk) => (data += chunk));
req.on("end", () => resolve(new URLSearchParams(data)));
});
}
http
.createServer(async (req, res) => {
const encoded = (req.headers.authorization ?? "").split(" ")[1] ?? "";
const password = Buffer.from(encoded, "base64").toString().split(":").slice(1).join(":");
if (!APP_PASSWORD || password !== APP_PASSWORD) {
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="ghostwriter"' }).end("Password required");
return;
}
let view = {};
try {
if (req.method === "POST" && req.url === "/draft") {
const notes = (await readBody(req)).get("notes");
view = { notes, drafts: await draft(notes) };
} else if (req.method === "POST" && req.url === "/publish") {
const form = await readBody(req);
const scheduledAt = form.get("action") === "schedule" ? form.get("scheduledAt") : null;
if (form.get("action") === "schedule" && !scheduledAt) throw new Error("Pick a date and time to schedule.");
await hapi("/posts", {
method: "POST",
body: JSON.stringify({ content: form.get("content"), accountIds: form.getAll("accountId"), scheduledAt }),
});
view = { message: scheduledAt ? `Scheduled for ${new Date(scheduledAt).toLocaleString()} ✓` : "Posted ✓" };
}
} catch (err) {
view = { ...view, message: `Error: ${err.message}` };
}
try {
const html = await page(view);
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(html);
} catch (err) {
res.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" }).end(`Couldn't reach Hapi: ${err.message}`);
}
})
.listen(PORT, () => console.log(`Your ghostwriter is on http://localhost:${PORT}`));Lancez-la :
ANTHROPIC_API_KEY=... HAPI_API_KEY=hapi_live_... APP_PASSWORD=pick-one node taplio-clone.mjsTapez une idée brute (« on a recruté notre premier designer, ce que je ferais autrement »), cliquez sur Draft 3 posts, retouchez celui qui vous plaît, puis publiez-le ou planifiez-le. LinkedIn est déjà coché ; cochez X ou Bluesky pour la publication croisée. Hapi vérifie les limites de chaque réseau avant d’accepter : un brouillon trop long pour X produit une erreur claire, pas un post en échec.
Ce que Hapi et Claude ont pris en charge
- Claude : écrire dans votre voix, trois angles distincts, un JSON toujours valide, et un repli si une requête est refusée
- Hapi : OAuth LinkedIn et rafraîchissement des tokens, la limite de 3 000 caractères, la publication à la minute planifiée, les relances, et les identifiants chiffrés
Vos 120 lignes, c’est le produit : l’écran, le prompt et le parcours.
Version sans code : Claude publie pour vous via MCP
Hapi expose un serveur MCP. Claude Code (ou n’importe quel client MCP) peut donc rédiger et publier, sans application à écrire :
claude mcp add --transport http hapi https://hapi.tools/mcp --header "Authorization: Bearer $HAPI_API_KEY"Puis demandez : « Lis mes 5 derniers posts LinkedIn dans voice.txt, rédige 3 nouveaux posts sur le recrutement de notre premier designer dans la même voix, et enregistre le meilleur dans Hapi comme brouillon pour mon compte LinkedIn. » Claude appelle l’outil create_post de Hapi avec draft: true, et vous validez dans le tableau de bord.
En faire un vrai produit
- Un calendrier de publication : reprenez la file du clone de Buffer, pour que « Add to queue » choisisse le prochain créneau libre.
- Des images : envoyez-les avec
POST /v1/mediaet joignezmediaIds. - Un premier commentaire : sur LinkedIn, le lien est souvent dans le premier commentaire. Passez
firstCommentet Hapi le publie juste après. - Une version par réseau : demandez aussi à Claude une version X de 280 caractères, et envoyez les deux dans la même requête avec
platformOptions. - Des clients : sur Hapi Pro, donnez à chaque client un profil et un fichier de voix, et faites tourner un ghostwriter par client.
Qui peut s’en servir
Tel qu’il est construit ici, le ghostwriter publie sur les comptes connectés dans votre espace de travail Hapi : vous, votre équipe, ou les clients que vous gérez. En faire un SaaS public, où n’importe qui s’inscrit et connecte son propre LinkedIn, demande une connexion de comptes via l’API, que Hapi ne propose pas encore.
Construisez le vôtre : 15 jours pour 5 $ →
Taplio est une marque de son propriétaire. Ce tutoriel n’est ni affilié à Taplio ni approuvé par Taplio. « Clone de Taplio » décrit le flux de rédaction assistée que ce guide recrée.