· 7 min de lectura
Cómo crear un clon de Taplio: un ghostwriter de LinkedIn con IA, sobre Claude y Hapi
Crea tu propio ghostwriter de LinkedIn con IA: Claude redacta tres publicaciones con tu voz, tú eliges y editas una, y Hapi la publica o la programa. Unas 120 líneas de Node, más una versión sin código por MCP.
El bucle de Taplio es sencillo: le das un tema, te devuelve publicaciones de LinkedIn que suenan como tú y programas la mejor. Detrás hay dos piezas de infraestructura:
- Un modelo que escribe bien con tu voz. Eso es Claude.
- Una forma fiable de publicar en LinkedIn. OAuth, renovación de tokens, el límite de 3000 caracteres, la programación y los reintentos. De eso se encarga Hapi.
Tú escribes lo que hay en medio: unas 120 líneas. Al terminar tendrás:
- Una caja donde escribes un tema o unas notas sueltas
- Tres borradores de Claude, cada uno con un ángulo distinto, escritos con tu voz (aprendida de publicaciones que ya has escrito)
- La posibilidad de editar cualquier borrador y luego publicarlo ya o programarlo en LinkedIn (y también en X o Bluesky, si quieres)
Al final hay una versión sin código, por si prefieres que Claude publique por ti.
1. Preparación
- Empieza una prueba de Hapi (15 días por 5 USD) y conecta LinkedIn en Accounts (OAuth en un clic). Crea una clave de API.
- Consigue una clave de la API de Claude en platform.claude.com.
- Crea una carpeta e instala el SDK de Anthropic:
mkdir taplio-clone && cd taplio-clone
npm init -y && npm install @anthropic-ai/sdk- Enséñale tu voz: pega entre 3 y 10 publicaciones antiguas de LinkedIn en
voice.txt, separadas por una línea con---.
2. Redactar con tu voz
El truco está en el prompt de sistema: tus publicaciones reales entran como ejemplos y Claude devuelve exactamente tres borradores en JSON, así que la app no tiene que adivinar dónde termina cada uno. Las salidas estructuradas garantizan 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 petición usa Claude Opus 5.5 con fallbacks: "default": si un clasificador de seguridad rechaza una petición, la API la reintenta en el modelo de respaldo que recomienda Anthropic, en lugar de fallar.
El prompt del ejemplo, dentro del código de más abajo, está en inglés, igual que los textos de la pantalla. Si publicas en español, reescríbelo en español: es lo que le indica a Claude el idioma y la voz.
3. La aplicación completa
// taplio-clone.mjs: un ghostwriter de LinkedIn con IA, sobre Claude y Hapi.
// Ejecuta: 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(); // lee 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>
// Convierte la hora local elegida en una marca UTC exacta antes de enviarla.
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}`));Ejecútalo:
ANTHROPIC_API_KEY=... HAPI_API_KEY=hapi_live_... APP_PASSWORD=pick-one node taplio-clone.mjsEscribe una idea suelta («contratamos a nuestra primera diseñadora; qué haría distinto»), pulsa Draft 3 posts, retoca el borrador que te guste y publícalo o prográmalo. LinkedIn viene marcado; marca X o Bluesky para publicar también ahí. Hapi comprueba los límites de cada red antes de aceptar, así que un borrador demasiado largo para X devuelve un error claro, no una publicación fallida.
Lo que Hapi y Claude resolvieron por ti
- Claude: escribir con tu voz, tres ángulos distintos, JSON con la forma garantizada y un modelo de respaldo si una petición es rechazada
- Hapi: el OAuth de LinkedIn y la renovación de tokens, el límite de 3000 caracteres, publicar en el minuto programado, los reintentos y las credenciales cifradas
Tus 120 líneas son solo el producto: la pantalla, el prompt y el flujo.
Versión sin código: Claude publica por ti mediante MCP
Hapi expone un servidor MCP, así que Claude Code (o cualquier cliente MCP) puede redactar y publicar sin necesidad de una app:
claude mcp add --transport http hapi https://hapi.tools/mcp --header "Authorization: Bearer $HAPI_API_KEY"Después pídele: «Lee mis últimas 5 publicaciones de LinkedIn en voice.txt, redacta 3 publicaciones nuevas sobre contratar a nuestra primera diseñadora, con la misma voz, y guarda la mejor en Hapi como borrador de mi cuenta de LinkedIn.» Claude llama a la herramienta create_post de Hapi con draft: true, y tú la apruebas en el panel.
Conviértelo en un producto de verdad
- Un calendario de publicación: reutiliza la cola de nuestro clon de Buffer para que «añadir a la cola» elija la siguiente franja libre.
- Imágenes: súbelas con
POST /v1/mediay adjuntamediaIds. - Un primer comentario: en LinkedIn el enlace suele ir en el primer comentario. Pasa
firstCommenty Hapi lo publica justo después. - Versiones por red: pídele a Claude también una versión de 280 caracteres para X y envía las dos en una sola petición con
platformOptions. - Clientes: en Hapi Pro, dale a cada cliente un perfil y un archivo de voz, y ejecuta un ghostwriter por cliente.
Quién puede usarlo
Tal como está montado aquí, el ghostwriter publica en las cuentas conectadas a tu espacio de trabajo de Hapi: tú, tu equipo o los clientes que gestionas. Convertirlo en un SaaS público, donde cualquiera se registra y conecta su propio LinkedIn, exige conectar cuentas a través de la API, y eso Hapi todavía no lo ofrece.
Crea el tuyo: 15 días por 5 USD →
Taplio es una marca registrada de su propietario. Este tutorial no está afiliado a Taplio ni cuenta con su respaldo; «clon de Taplio» describe el flujo de redacción con IA que reproduce.