· 7 min di lettura
Come costruire un clone di Taplio: un ghostwriter AI per LinkedIn con Claude e Hapi
Costruisci il tuo ghostwriter AI per LinkedIn: Claude prepara tre post con la tua voce, tu ne scegli uno e lo modifichi, Hapi lo pubblica o lo programma. Circa 120 righe di Node, più una versione senza codice via MCP.
Il ciclo di Taplio è semplice: gli dai un argomento, ottieni post per LinkedIn che sembrano scritti da te, programmi il migliore. A farlo funzionare sono due pezzi di infrastruttura:
- Un modello che scrive bene con la tua voce. Quello è Claude.
- Un modo affidabile di pubblicare su LinkedIn. OAuth, refresh dei token, il limite di 3.000 caratteri, programmazione e retry. Quello è Hapi.
Tu scrivi il pezzo in mezzo: circa 120 righe. Alla fine avrai:
- Una casella in cui scrivi un argomento o degli appunti grezzi
- Tre bozze di Claude, ognuna con un angolo diverso, scritte con la tua voce (imparata dai post che hai già pubblicato)
- La possibilità di modificare qualsiasi bozza, poi Post now oppure Schedule sul tuo LinkedIn (e anche su X o Bluesky, se vuoi)
In fondo c’è anche una versione senza codice, se vuoi solo che sia Claude a pubblicare per te.
1. Configurazione
- Inizia una prova di Hapi (15 giorni a 5 $) e collega LinkedIn sotto Accounts (OAuth in un clic). Crea una chiave API.
- Ottieni una chiave API di Claude su platform.claude.com.
- Crea una cartella e installa l’SDK di Anthropic:
mkdir taplio-clone && cd taplio-clone
npm init -y && npm install @anthropic-ai/sdk- Insegnagli la tua voce: incolla da 3 a 10 dei tuoi post LinkedIn passati in
voice.txt, separati da una riga con---.
2. Bozze con la tua voce
Il trucco è il prompt di sistema: i tuoi post veri entrano come esempi, e Claude restituisce esattamente tre bozze in JSON, così l’app non deve indovinare dove finisce una bozza. Gli output strutturati garantiscono la forma:
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 richiesta usa Claude Opus 5.5 con fallbacks: "default": se un classificatore di sicurezza rifiuta la richiesta, l’API la ritenta sul modello di fallback consigliato da Anthropic, invece di fallire.
3. L’app completa
// 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}`));Avvialo:
ANTHROPIC_API_KEY=... HAPI_API_KEY=hapi_live_... APP_PASSWORD=pick-one node taplio-clone.mjsScrivi un’idea grezza (“prima assunzione nel design: cosa rifarei in modo diverso”), premi Draft 3 posts, ritocca quella che ti convince e pubblicala o programmala. LinkedIn è già spuntato; spunta X o Bluesky per pubblicare anche lì. Hapi controlla i limiti di ogni network prima di accettare, quindi una bozza troppo lunga per X riceve un errore chiaro, non un post fallito.
Cosa hanno gestito Hapi e Claude al posto tuo
- Claude: scrivere con la tua voce, tre angoli distinti, JSON sicuramente valido, e un fallback se una richiesta viene rifiutata
- Hapi: OAuth di LinkedIn e refresh dei token, il limite di 3.000 caratteri, la pubblicazione al minuto programmato, i retry e le credenziali cifrate
Le tue 120 righe sono solo il prodotto: la schermata, il prompt e il flusso.
Versione senza codice: Claude pubblica per te via MCP
Hapi espone un server MCP, quindi Claude Code (o qualsiasi client MCP) può scrivere le bozze e pubblicarle senza un’app:
claude mcp add --transport http hapi https://hapi.tools/mcp --header "Authorization: Bearer $HAPI_API_KEY"Poi chiedi: “Leggi i miei ultimi 5 post LinkedIn in voice.txt, prepara 3 post nuovi sulla prima assunzione nel team di design, con la stessa voce, e salva il migliore su Hapi come bozza per il mio account LinkedIn.” Claude chiama il tool create_post di Hapi con draft: true, e tu la approvi nella dashboard di Hapi.
Farne un prodotto vero
- Un calendario di pubblicazione: riusa la coda del nostro clone di Buffer, così “Add to queue” sceglie il prossimo slot libero.
- Immagini: caricale con
POST /v1/mediae allegamediaIds. - Un primo commento: su LinkedIn il link sta spesso nel primo commento, quindi passa
firstCommente Hapi lo pubblica subito dopo. - Versioni per network: chiedi a Claude anche una versione da 280 caratteri per X, e invia entrambe in una sola richiesta con
platformOptions. - Clienti: con Hapi Pro dai a ogni cliente un profilo e un file della voce, e fai girare un ghostwriter per cliente.
Chi può usarlo
Così com’è, il ghostwriter pubblica sugli account collegati nel tuo workspace Hapi: tu, il team o i clienti che gestisci. Trasformarlo in un SaaS pubblico, dove chiunque si registra e collega il proprio LinkedIn, richiede la connessione degli account tramite API, che Hapi non offre ancora.
Costruisci il tuo: 15 giorni a 5 $ →
Taplio è un marchio del suo proprietario. Questo tutorial non è affiliato a Taplio né approvato da Taplio; “clone di Taplio” descrive il flusso di bozze con l’AI che ricostruisce.