블로그

· 6분 분량

Taplio 클론 만들기: Claude와 Hapi로 만드는 AI LinkedIn 고스트라이터

AI LinkedIn 고스트라이터를 직접 만드세요. Claude가 내 말투로 글 세 개를 쓰고, 하나를 골라 고친 뒤 Hapi가 게시하거나 예약합니다. Node 약 120줄, 그리고 MCP로 붙이는 노코드 버전도 있습니다.

Taplio의 핵심 흐름은 단순합니다. 주제를 주면 나처럼 들리는 LinkedIn 글이 나오고, 그중 가장 나은 글을 예약합니다. 이걸 가능하게 하는 인프라는 두 가지입니다.

  • 내 말투로 잘 쓰는 모델. 그게 Claude입니다.
  • LinkedIn에 안정적으로 게시하는 방법. OAuth, 토큰 갱신, 3,000자 제한, 예약, 재시도. 그게 Hapi입니다.

그 사이, 약 120줄은 여러분이 씁니다. 끝나면 이런 것이 갖춰집니다.

  • 주제나 거친 메모를 적는 칸
  • Claude가 만든 초안 세 개. 앵글은 다르고, 말투는 이미 쓴 글에서 배운 내 말투입니다
  • 초안을 고친 뒤 LinkedIn에 Post now 또는 Schedule. 원하면 X나 Bluesky에도

앱을 만들고 싶지 않다면, 맨 끝에 노코드 버전이 있습니다. Claude가 대신 올리게만 하면 됩니다.

1. 준비

  1. Hapi 체험을 시작하세요(15일 $5). Accounts에서 LinkedIn을 연결하고(클릭 한 번의 OAuth), API 키를 만드세요.
  2. platform.claude.com에서 Claude API 키를 받으세요.
  3. 폴더를 만들고 Anthropic SDK를 설치하세요.
bash
mkdir taplio-clone && cd taplio-clone
npm init -y && npm install @anthropic-ai/sdk
  1. 말투를 가르치세요. 예전에 쓴 LinkedIn 글 3~10개를 voice.txt에 붙여 넣고, --- 한 줄로 구분하세요.

2. 내 말투로 초안 쓰기

핵심은 시스템 프롬프트입니다. 실제 글을 예시로 넣고, Claude가 초안 정확히 세 개를 JSON으로 돌려주게 하면 앱이 초안의 경계를 추측할 필요가 없습니다. 구조화된 출력이 형태를 보장합니다.

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

요청은 Claude Opus 5.5와 fallbacks: "default"를 씁니다. 안전 분류기가 요청을 거절하면, API가 실패하는 대신 Anthropic이 권하는 폴백 모델로 다시 시도합니다.

3. 앱 전체

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}`));

실행합니다.

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

거친 아이디어를 적고(“첫 디자이너를 뽑았다. 다시 한다면 이렇게 하겠다”), Draft 3 posts를 누른 뒤, 마음에 드는 글을 다듬어 게시하거나 예약하세요. LinkedIn은 미리 체크되어 있습니다. X나 Bluesky를 체크하면 같이 올라갑니다. Hapi는 수락하기 전에 각 네트워크의 제한을 확인하므로, X에 너무 긴 초안은 실패한 게시가 아니라 분명한 오류로 돌아옵니다.

Hapi와 Claude가 대신 처리한 것

  • Claude. 내 말투로 쓰기, 서로 다른 세 가지 앵글, 형식이 보장된 JSON, 요청이 거절됐을 때의 폴백
  • Hapi. LinkedIn OAuth와 토큰 갱신, 3,000자 제한, 예약한 분에 정확히 게시, 재시도, 암호화된 자격 증명

여러분이 쓴 120줄은 제품입니다. 화면, 프롬프트, 그리고 흐름.

노코드 버전: MCP로 Claude가 대신 게시

Hapi는 MCP 서버를 띄웁니다. 그래서 Claude Code나 다른 MCP 클라이언트가 앱 없이도 초안을 쓰고 게시할 수 있습니다.

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

그리고 이렇게 부탁하세요. “voice.txt에 있는 최근 LinkedIn 글 5개를 읽고, 같은 말투로 첫 디자이너 채용에 대한 새 글 3개를 쓴 다음, 가장 나은 글을 내 LinkedIn 계정용 Hapi 초안으로 저장해 줘.” Claude는 draft: true로 Hapi의 create_post 도구를 호출하고, 여러분은 Hapi 대시보드에서 승인합니다.

진짜 제품으로 만들기

  • 게시 일정. Buffer 클론의 큐를 재사용하면 “Add to queue”가 다음 빈 슬롯을 고릅니다.
  • 이미지. POST /v1/media로 올리고 mediaIds를 붙이세요.
  • 첫 댓글. LinkedIn에서는 링크를 첫 댓글에 넣는 경우가 많습니다. firstComment를 넘기면 Hapi가 글 바로 뒤에 올립니다.
  • 네트워크별 버전. Claude에게 280자 X 버전도 받게 한 뒤, platformOptions로 한 요청에 둘 다 보내세요.
  • 클라이언트. Hapi Pro에서는 클라이언트마다 프로필과 말투 파일을 두고, 고스트라이터도 클라이언트마다 하나씩 돌리세요.

누가 쓸 수 있나

여기서 만든 고스트라이터는 여러분의 Hapi 워크스페이스에 연결된 계정에 게시합니다. 나, 우리 팀, 또는 내가 관리하는 클라이언트입니다. 누구나 가입해서 자기 LinkedIn을 연결하는 공개 SaaS로 만들려면 API로 계정을 연결할 수 있어야 하는데, Hapi는 아직 그 기능을 제공하지 않습니다.

직접 만들어 보세요. 15일 $5 →

Taplio는 해당 소유자의 상표입니다. 이 튜토리얼은 Taplio와 제휴하거나 Taplio의 승인을 받은 것이 아니며, “Taplio 클론”은 재현하는 AI 초안 작성 흐름을 가리킵니다.