Blog

· 6 min read

How to build a Taplio clone: an AI LinkedIn ghostwriter on Claude + Hapi

Build your own AI LinkedIn ghostwriter: Claude drafts three posts in your voice, you pick and edit one, Hapi publishes or schedules it. About 120 lines of Node, plus a no-code version over MCP.

Taplio's core loop is simple: give it a topic, get LinkedIn posts that sound like you, schedule the best one. Two pieces of infrastructure make that work:

  • A model that writes well in your voice. That's Claude.
  • A reliable way to publish to LinkedIn. OAuth, token refresh, the 3,000-character limit, scheduling and retries. That's Hapi.

You write the part in between: about 120 lines. By the end you'll have:

  • A box where you type a topic or rough notes
  • Three drafts from Claude, each with a different angle, written in your voice (learned from posts you've already written)
  • Edit any draft, then Post now or Schedule it to your LinkedIn (and X or Bluesky too, if you like)

There's also a no-code version at the end, if you just want Claude to post for you.

1. Set up

  1. Start a Hapi trial (15 days for $5) and connect LinkedIn under Accounts (one-click OAuth). Create an API key.
  2. Get a Claude API key at platform.claude.com.
  3. Make a folder and install Anthropic's SDK:
bash
mkdir taplio-clone && cd taplio-clone
npm init -y && npm install @anthropic-ai/sdk
  1. Teach it your voice: paste 3–10 of your past LinkedIn posts into voice.txt, separated by a line with ---.

2. Drafting in your voice

The trick is the system prompt: your real posts go in as examples, and Claude returns exactly three drafts as JSON, so the app never has to guess where one draft ends. Structured outputs guarantee the shape:

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

The request uses Claude Opus 5.5 with fallbacks: "default": if a request is ever declined by a safety classifier, the API retries it on Anthropic's recommended fallback model instead of failing.

3. The whole 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}`));

Run it:

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

Type a rough idea ("hired our first designer, what I'd do differently"), hit Draft 3 posts, tweak the one you like, and post or schedule it. LinkedIn is pre-ticked; tick X or Bluesky to cross-post. Hapi checks each network's limits before accepting, so a draft that's too long for X gets a clear error instead of a failed post.

What Hapi and Claude handled for you

  • Claude: writing in your voice, three distinct angles, guaranteed-valid JSON, and a fallback if a request is declined
  • Hapi: LinkedIn OAuth and token refresh, the 3,000-character limit, publishing at exactly the scheduled minute, retries, and encrypted credentials

Your 120 lines are just the product: the screen, the prompt and the flow.

No-code version: Claude posts for you over MCP

Hapi runs an MCP server, so Claude Code (or any MCP client) can draft and publish without an app:

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

Then ask: "Read my last 5 LinkedIn posts in voice.txt, draft 3 new posts about hiring our first designer in the same voice, and save the best one to Hapi as a draft for my LinkedIn account." Claude calls Hapi's create_post tool with draft: true, and you approve it in the Hapi dashboard.

Make it a real product

  • A posting schedule: reuse the queue from our Buffer clone so "Add to queue" picks the next free slot.
  • Images: upload with POST /v1/media and attach mediaIds.
  • A first comment: LinkedIn posts often put the link in the first comment, so pass firstComment and Hapi posts it right after.
  • Per-network versions: ask Claude for a 280-character X version too, and send both in one request with platformOptions.
  • Clients: on Hapi Pro, give each client a profile and a voice file, and run one ghostwriter per client.

Who can use it

As built here, the ghostwriter posts to the accounts connected in your Hapi workspace: you, your team, or the clients you manage. Turning it into a public SaaS where anyone signs up and connects their own LinkedIn needs account connection through the API, which Hapi doesn't offer yet.

Build yours: 15 days for $5 →

Taplio is a trademark of its owner. This tutorial isn't affiliated with or endorsed by Taplio; "Taplio clone" describes the AI-drafting workflow it recreates.