Blog

· 7 min leestijd

Zo bouw je een Taplio-kloon: een AI-ghostwriter voor LinkedIn op Claude + Hapi

Bouw je eigen AI-ghostwriter voor LinkedIn: Claude schrijft drie berichten in jouw stem, jij kiest en bewerkt er één, Hapi publiceert of plant het in. Ongeveer 120 regels Node, plus een no-codeversie via MCP.

De kernloop van Taplio is simpel: geef een onderwerp, krijg LinkedIn-berichten die als jij klinken, plan de beste in. Twee stukken infrastructuur maken dat mogelijk:

  • Een model dat goed schrijft in jouw stem. Dat is Claude.
  • Een betrouwbare manier om naar LinkedIn te publiceren. OAuth, token-refresh, de limiet van 3.000 tekens, inplannen en retries. Dat is Hapi.

Jij schrijft het stuk ertussen: zo'n 120 regels. Aan het eind heb je:

  • Een vak waarin je een onderwerp of ruwe notities typt
  • Drie concepten van Claude, elk met een andere invalshoek, geschreven in jouw stem (geleerd van berichten die je al hebt geschreven)
  • Bewerk een concept, en Nu posten of Inplannen naar je LinkedIn (en ook naar X of Bluesky, als je wilt)

Aan het eind staat ook een no-codeversie, als je alleen wilt dat Claude voor je post.

1. Klaarzetten

  1. Start een Hapi-proefperiode (15 dagen voor $5) en koppel LinkedIn onder Accounts (OAuth in één klik). Maak een API-key aan.
  2. Haal een Claude API-key op bij platform.claude.com.
  3. Maak een map en installeer de SDK van Anthropic:
bash
mkdir taplio-clone && cd taplio-clone
npm init -y && npm install @anthropic-ai/sdk
  1. Leer het je stem: plak 3–10 van je eerdere LinkedIn-berichten in voice.txt, gescheiden door een regel met ---.

2. Schrijven in jouw stem

De truc zit in de systeemprompt: je echte berichten gaan erin als voorbeelden, en Claude geeft precies drie concepten terug als JSON, zodat de app nooit hoeft te raden waar het ene concept stopt. Structured outputs garanderen de vorm:

js
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,
};

De request gebruikt Claude Opus 5.5 met fallbacks: "default": als een request ooit wordt geweigerd door een safety-classifier, probeert de API het opnieuw op het aanbevolen fallback-model van Anthropic in plaats van te falen.

3. De hele app

js
// taplio-clone.mjs: een AI-ghostwriter voor LinkedIn op Claude + Hapi.
// Starten: ANTHROPIC_API_KEY=... HAPI_API_KEY=hapi_live_... APP_PASSWORD=kies-er-een 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(); // leest 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:
      "Je schrijft LinkedIn-berichten als ghostwriter voor één persoon. Volg de stem, het ritme, de opmaak en de woordkeuze van de eerdere berichten hieronder. " +
      "Schrijf precies 3 concepten, elk met een andere invalshoek (bijvoorbeeld: een verhaal, een les, een tegendraadse stelling). " +
      "Elk concept blijft onder de 1.300 tekens, opent met een sterke eerste regel en gebruikt geen hashtags, tenzij de eerdere berichten dat doen.\n\n" +
      `<past_posts>\n${VOICE}\n</past_posts>`,
    messages: [{ role: "user", content: `Onderwerp of notities voor het bericht van vandaag:\n\n${notes}` }],
  });
  if (response.stop_reason === "refusal") throw new Error("Claude heeft dit onderwerp afgewezen. Probeer het anders te formuleren.");
  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 antwoordde met ${res.status}`);
  return body;
}

const esc = (s = "") => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[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="Waar denk je aan? Ruwe notities zijn prima.">${esc(notes)}</textarea><button>Schrijf 3 berichten</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">Inplannen</button> <button name="action" value="now">Nu posten</button></p></form>`).join("")}
  <script>
    // Zet de gekozen lokale tijd om naar een exact UTC-tijdstip voordat het verstuurd wordt.
    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("Wachtwoord vereist");
      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("Kies een datum en tijd om in te plannen.");
        await hapi("/posts", {
          method: "POST",
          body: JSON.stringify({ content: form.get("content"), accountIds: form.getAll("accountId"), scheduledAt }),
        });
        view = { message: scheduledAt ? `Ingepland voor ${new Date(scheduledAt).toLocaleString("nl-NL")} ✓` : "Gepost ✓" };
      }
    } catch (err) {
      view = { ...view, message: `Fout: ${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(`Hapi was niet bereikbaar: ${err.message}`);
    }
  })
  .listen(PORT, () => console.log(`Je ghostwriter draait op http://localhost:${PORT}`));

Starten:

bash
ANTHROPIC_API_KEY=... HAPI_API_KEY=hapi_live_... APP_PASSWORD=kies-er-een node taplio-clone.mjs

Typ een ruw idee ("eerste designer aangenomen, wat ik anders zou doen"), klik op Schrijf 3 berichten, pas het concept aan dat je goed vindt, en post of plan het in. LinkedIn staat al aangevinkt; vink X of Bluesky aan om mee te posten. Hapi controleert de limieten van elk netwerk voordat het accepteert, dus een concept dat te lang is voor X krijgt een duidelijke fout in plaats van een mislukte post.

Wat Hapi en Claude voor je deden

  • Claude: schrijven in jouw stem, drie verschillende invalshoeken, gegarandeerd geldige JSON, en een fallback als een request wordt afgewezen
  • Hapi: LinkedIn-OAuth en token-refresh, de limiet van 3.000 tekens, publiceren op precies de ingeplande minuut, retries en versleutelde credentials

Je 120 regels zijn alleen het product: het scherm, de prompt en de flow.

No-codeversie: Claude post voor je via MCP

Hapi draait een MCP-server, zodat Claude Code (of elke MCP-client) kan opstellen en publiceren zonder app:

bash
claude mcp add --transport http hapi https://hapi.tools/mcp --header "Authorization: Bearer $HAPI_API_KEY"

Vraag daarna: "Lees mijn laatste 5 LinkedIn-berichten in voice.txt, schrijf 3 nieuwe berichten over het aannemen van onze eerste designer in dezelfde stem, en sla de beste op in Hapi als concept voor mijn LinkedIn-account." Claude roept de create_post-tool van Hapi aan met draft: true, en jij keurt het goed in het Hapi-dashboard.

Maak er een echt product van

  • Een postschema: hergebruik de wachtrij uit onze Buffer-kloon, zodat "In de wachtrij" het eerstvolgende vrije moment kiest.
  • Afbeeldingen: upload met POST /v1/media en hang mediaIds eraan.
  • Een eerste reactie: bij LinkedIn-berichten staat de link vaak in de eerste reactie, dus geef firstComment mee en Hapi plaatst die meteen erna.
  • Versies per netwerk: vraag Claude ook om een X-versie van 280 tekens, en stuur beide in één request mee met platformOptions.
  • Klanten: geef op Hapi Pro elke klant een profiel en een voice-bestand, en draai één ghostwriter per klant.

Wie het kan gebruiken

Zoals het hier staat, post de ghostwriter naar de accounts die in jouw Hapi-workspace gekoppeld zijn: jij, je team, of de klanten die je beheert. Er een openbare SaaS van maken, waar iedereen zich aanmeldt en zijn eigen LinkedIn koppelt, vraagt om accounts koppelen via de API. Dat biedt Hapi nog niet.

Bouw de jouwe: 15 dagen voor $5 →

Taplio is een handelsmerk van de eigenaar. Deze tutorial is niet gelieerd aan Taplio en wordt er niet door ondersteund; "Taplio-kloon" beschrijft de AI-schrijfworkflow die hij nabouwt.