Blog

· 7 Min. Lesezeit

Einen Taplio-Klon bauen: ein KI-Ghostwriter für LinkedIn mit Claude und Hapi

Bau dir deinen eigenen KI-Ghostwriter für LinkedIn: Claude schreibt drei Beiträge in deiner Stimme, du suchst dir einen aus und bearbeitest ihn, Hapi veröffentlicht oder plant ihn. Rund 120 Zeilen Node, plus eine No-Code-Variante über MCP.

Die Schleife von Taplio ist einfach: Du gibst ein Thema rein, bekommst LinkedIn-Beiträge, die nach dir klingen, und planst den besten. Zwei Bausteine machen das möglich:

  • Ein Modell, das gut in deiner Stimme schreibt. Das ist Claude.
  • Ein verlässlicher Weg, auf LinkedIn zu veröffentlichen. OAuth, Token-Refresh, das Limit von 3.000 Zeichen, Planung und Wiederholungen. Das ist Hapi.

Den Teil dazwischen schreibst du selbst: ungefähr 120 Zeilen. Am Ende hast du:

  • ein Feld, in das du ein Thema oder grobe Notizen tippst
  • drei Entwürfe von Claude, jeder mit einem anderen Blickwinkel, geschrieben in deiner Stimme (gelernt aus Beiträgen, die du schon geschrieben hast)
  • die Möglichkeit, jeden Entwurf zu bearbeiten und dann mit Post now sofort zu veröffentlichen oder mit Schedule zu planen – auf LinkedIn, und auf X oder Bluesky dazu, wenn du willst

Am Ende gibt es auch eine No-Code-Variante, falls Claude einfach für dich posten soll.

1. Einrichten

  1. Starte eine Hapi-Testphase (15 Tage für 5 $) und verbinde LinkedIn unter Accounts (OAuth mit einem Klick). Erstelle einen API-Key.
  2. Hol dir einen Claude-API-Key unter platform.claude.com.
  3. Leg einen Ordner an und installiere das SDK von Anthropic:
bash
mkdir taplio-clone && cd taplio-clone
npm init -y && npm install @anthropic-ai/sdk
  1. Bring ihm deine Stimme bei: Füg 3–10 deiner bisherigen LinkedIn-Beiträge in voice.txt ein, getrennt durch eine Zeile mit ---.

2. In deiner Stimme entwerfen

Der Trick steckt im System-Prompt: Deine echten Beiträge gehen als Beispiele rein, und Claude liefert genau drei Entwürfe als JSON. Die App muss also nie raten, wo ein Entwurf aufhört. Strukturierte Ausgaben (Structured Outputs) legen die Form fest:

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

Die Anfrage nutzt Claude Opus 5.5 mit fallbacks: "default". Lehnt ein Safety-Classifier eine Anfrage ab, wiederholt die API sie auf dem von Anthropic empfohlenen Fallback-Modell, statt zu scheitern.

3. Die ganze App

js
// 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) => ({ "&": "&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="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}`));

So startest du sie:

bash
ANTHROPIC_API_KEY=... HAPI_API_KEY=hapi_live_... APP_PASSWORD=pick-one node taplio-clone.mjs

Tippe eine grobe Idee ein („unseren ersten Designer eingestellt, was ich anders machen würde“), klick auf Draft 3 posts, überarbeite den Entwurf, der dir gefällt, und veröffentliche oder plane ihn. LinkedIn ist vorausgewählt. Setz bei X oder Bluesky den Haken, um parallel dort zu posten. Hapi prüft die Limits jedes Netzwerks, bevor es den Beitrag annimmt. Ein Entwurf, der für X zu lang ist, bekommt einen klaren Fehler statt eines fehlgeschlagenen Beitrags.

Was Hapi und Claude für dich übernommen haben

  • Claude: Schreiben in deiner Stimme, drei klar getrennte Blickwinkel, garantiert gültiges JSON und ein Fallback, falls eine Anfrage abgelehnt wird
  • Hapi: LinkedIn-OAuth und Token-Refresh, das Limit von 3.000 Zeichen, Veröffentlichung genau in der geplanten Minute, Wiederholungen und verschlüsselte Zugangsdaten

Deine 120 Zeilen sind nur das Produkt: die Oberfläche, der Prompt und der Ablauf.

No-Code-Variante: Claude postet für dich über MCP

Hapi betreibt einen MCP-Server. Claude Code (oder jeder andere MCP-Client) kann damit entwerfen und veröffentlichen, ganz ohne eigene App:

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

Dann frag zum Beispiel: „Lies meine letzten 5 LinkedIn-Beiträge in voice.txt, entwirf 3 neue Beiträge darüber, wie wir unseren ersten Designer eingestellt haben, in derselben Stimme, und speichere den besten bei Hapi als Entwurf für mein LinkedIn-Konto.“ Claude ruft Hapis Tool create_post mit draft: true auf, und du gibst den Beitrag im Hapi-Dashboard frei.

Daraus ein echtes Produkt machen

  • Ein Posting-Plan: Nutz die Warteschlange aus unserem Buffer-Klon wieder, damit „Add to queue“ das nächste freie Zeitfenster wählt.
  • Bilder: Hochladen mit POST /v1/media und mediaIds anhängen.
  • Ein erster Kommentar: Bei LinkedIn liegt der Link oft im ersten Kommentar. Übergib firstComment, dann postet Hapi ihn direkt danach.
  • Fassungen pro Netzwerk: Bitte Claude zusätzlich um eine X-Fassung mit 280 Zeichen und schick beide in einer Anfrage über platformOptions.
  • Kunden: Mit Hapi Pro bekommt jeder Kunde ein Profil und eine Stimmdatei, und du betreibst einen Ghostwriter pro Kunde.

Wer es nutzen kann

So wie es hier gebaut ist, postet der Ghostwriter auf die Konten in deinem Hapi-Workspace: du, dein Team oder die Kunden, die du betreust. Daraus ein öffentliches SaaS zu machen, bei dem sich jede Person anmeldet und das eigene LinkedIn verbindet, braucht eine Kontoverbindung über die API. Die bietet Hapi noch nicht.

Bau deinen: 15 Tage für 5 $ →

Taplio ist eine Marke des Rechteinhabers. Dieses Tutorial steht in keiner Verbindung zu Taplio und wird nicht von Taplio unterstützt. „Taplio-Klon“ beschreibt den KI-Entwurfsablauf, den es nachbaut.