ブログ

· 4分で読めます

Taplioクローンの作り方。ClaudeとHapiで作る、AIのLinkedInゴーストライター

AIのLinkedInゴーストライターを自作します。Claudeがあなたの文体で投稿案を3つ書き、選んで編集したものをHapiが公開または予約します。Nodeは約120行。MCPで動くノーコード版もあります。

Taplioの中心にあるループは単純です。トピックを渡すと、あなたらしいLinkedInの投稿が返り、いちばんよいものを予約します。それを成立させる基盤は2つです。

  • あなたの声でうまく書くモデル。 それがClaudeです。
  • LinkedInへ確実に公開する手段。 OAuth、トークン更新、3,000文字の制限、予約、リトライ。それが Hapi です。

あいだを書くのはあなたです。およそ120行。最後には、次のものが手元にあります。

  • トピックや雑なメモを入れる欄
  • Claudeからの下書き3つ。切り口はそれぞれ違い、文体はすでに書いた投稿から学んだ あなた のものです
  • どの下書きも編集でき、LinkedInへ Post now(今すぐ投稿)または Schedule(予約)できます。希望ならXやBlueskyも

最後に ノーコード版 もあります。Claudeに投稿まで任せたいだけの場合です。

1. 準備

  1. Hapiのトライアルを始める(15日間 $5)。Accounts でLinkedInを連携します(ワンクリックのOAuth)。APIキーを作ります。
  2. ClaudeのAPIキーを platform.claude.com で取得します。
  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には下書きをちょうど3つ、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" を使います。安全性の分類器がリクエストを拒否しても、失敗せず、Anthropicが推奨するフォールバックモデルで再試行します。

3. アプリ全体

画面のボタンはサンプルのまま英語です。Draft 3 posts が3案の作成、Schedule が予約、Post now が今すぐ投稿です。

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: あなたの声で書くこと、3つの別の切り口、必ず妥当な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 にある直近5本のLinkedIn投稿を読み、初めてのデザイナーを雇うことについて、同じ声で新しい投稿を3つ下書きし、いちばんよいものを私のLinkedInアカウント向けの下書きとしてHapiに保存して。」 ClaudeはHapiの create_post ツールを draft: true で呼びます。承認はHapiのダッシュボードで行います。

本物のプロダクトにする

  • 投稿スケジュール: Bufferクローンのキューを再利用し、「Add to queue」が次の空き枠を選ぶようにします。
  • 画像: POST /v1/media でアップロードし、mediaIds を付けます。
  • 最初のコメント: LinkedInではリンクを最初のコメントに置くことが多いので、firstComment を渡すと、Hapiが直後に投稿します。
  • ネットワークごとの版: X向けの280文字版もClaudeに頼んで、platformOptions で1回のリクエストに両方を入れます。
  • クライアント: Hapi Proでは、クライアントごとにプロフィールと声のファイルを渡し、ゴーストライターを1つずつ走らせます。

誰が使えるか

ここで作ったゴーストライターが投稿するのは、あなた のHapiワークスペースに連携したアカウントです。あなた、チーム、運用しているクライアントです。誰でも登録して自分のLinkedInを連携する公開SaaSにするには、API経由のアカウント連携が必要で、Hapiはまだそれを提供していません。

作ってみる。15日間 $5 →

Taplioは権利者の商標です。このチュートリアルはTaplioとは無関係で、Taplioによる推奨もありません。「Taplioクローン」は、再現しているAI下書きのワークフローを指します。