Skip to content
Published on

Notion・Slack・LinearをLLMで束ねるワークフロー自動化ハンズオン — 2026年版・社内ツールのグルーコードガイド

Authors

プロローグ — Zapierの請求書が350ドルになった日

小さな社内ツールがあった。LinearでissueがDoneになった瞬間、GitHubのPRとコミットメッセージを集めてSlackチャンネルに要約通知を流し、Notionのchangelogデータベースに一行追加する。最初はZapierの5ステップZapだった。月の請求書が350ドルを記録した日に決めた — これは200行のスクリプトで十分だ。

この記事はその200行を最初から書き上げるハンズオンだ。束ねる相手はこちら。

  • Notion API — 2025年8月に公開されたData Sources APIでデータモデルが変わった。データベースはもう「1つのテーブル」ではない。
  • Slack API — Bolt 4 + Socket Mode + chat.postMessage + Incoming Webhook。どれをいつ選ぶか。
  • Linear GraphQL API@linear/sdk 4.x、Webhook署名、プロジェクトとメンバーシップのモデル。
  • LLMグルー — Claude/GPTを中間に挟んで分類・要約・ドラフトを任せる。単純なマッピングなら過剰だが、「PR7本をchangelog1行に圧縮」はLLMの得意分野。

この記事は先週公開したZapier vs n8n比較記事とペアだが、向こうはスナップショット比較で、こちらは実際に組むハンズオンである。

流れ。

  1. 事前準備 — トークン・シークレット・トリガーモデル決定
  2. Notion API 2026 — Data Sources APIの衝撃
  3. Slack API — Bolt 4 + Socket Modeハンズオン
  4. Linear GraphQL — SDKとWebhook
  5. LLMグルーパターン — どこで挟み、どこで抜くか
  6. フルスクリプト — Linearクローズ → Slack → Notion
  7. シークレット管理 — 1Password CLI・Doppler・Vercel環境変数
  8. 運用 — レート制限・リトライ・観測
  9. 正直な比較 — Zapier/n8nとのトレードオフ
  10. アンチパターン

読み終わる頃には、自チームの2〜3個の小さな自動化を自分で書こうという気になっているはず。


1章 · 事前準備 — トークン・シークレット・トリガーモデル

1.1 トークン種別 一行まとめ

プラットフォームトークン名権限モデル発行場所
NotionInternal Integration SecretCapabilityトグル + ページ/データベースの明示的共有notion.so/profile/integrations
SlackBot User OAuth Token (xoxb-) + App Token (xapp-)スコープ単位 (chat:writechannels:historyなど)api.slack.com/apps
LinearPersonal API KeyまたはOAuthワークスペース全体またはユーザー単位linear.app/settings/account/security
AnthropicAPI Keyワークスペース + 使用量上限console.anthropic.com

最大の落とし穴: Notionのインテグレーションは明示的に共有されていないページにはアクセスできない。インテグレーションを作成するだけでなく、対象のページ/データベースで「インテグレーションを追加」メニューを押す必要がある。新人がいちばん詰まる箇所。

1.2 トリガーモデル — Webhook vs ポーリング vs イベント購読

3プラットフォームとも3方式すべてに対応しているが、推奨モデルは違う。

  • Notion: 2024年からWebhookが正式サポート。database.content_updatedpage.createdcomment.createdなどのイベントタイプを登録できる。それでもポーリングはまだ多い — Notionの変更通知は部分的で、「このページの全変更」を追いたいなら結局再取得してdiffを取ることになる。
  • Slack: Events API + Webhook、そしてファイアウォール内で動かす場合のためのSocket Mode(WebSocket)がある。ローカル開発と小さな社内ツールにはSocket Modeのほうが圧倒的に楽。
  • Linear: Webhookが第一級市民。issue・project・comment・cycleなど、ほぼすべての変更にWebhookを張れる。GraphQLでポーリングするケースはまれ。

このハンズオンの流れはLinear Webhook → Slack通知 → Notion追加なので、「Linear Webhookを受ける小さなサーバー」が出発点になる。

1.3 どこで動かすか

よくある3択。

  • Vercel Functions / Cloudflare Workers — サーバーレス。コールドスタート100〜300ms、無料枠が手厚い、シークレットは環境変数で渡せる。難点はバックグラウンド処理が困難。30秒以内に応答する必要がある。
  • Bun/Node単一プロセス + systemdまたはFly.io — 常時起動、Socket Modeを使うならほぼこちら必須。月5〜10ドル。
  • Lambda + EventBridge — トラフィックが多くSLOが厳しい場合。セットアップコストが高め。

ハンズオンではBun + Fly.ioを前提にする。200行の社内ツールを動かす最もシンプルなモデル。


2章 · Notion API 2026 — Data Sources APIの衝撃

2.1 データモデルが変わった

2025年8月、Notionは**「Multi-source databases」**機能をローンチし、それに合わせてAPIにも大きな変更を入れた。1つのデータベース内に複数のデータソースを持てるようになり、APIもそれを反映する必要があった。

  • 以前(2024-09-25以前): データベース = 単一テーブル。databases.queryでページ(行)を取得していた。
  • 現在(2025-09-03以降): データベースが複数のdata sourceを内包する。ページはデータベース直下ではなくdata sourceの子になる。

新エンドポイント。

  • GET /v1/data_sources/:id — データソースのメタデータ。
  • POST /v1/data_sources/:id/query — ページのクエリ。旧POST /v1/databases/:id/queryの置き換え。
  • POST /v1/data_sources — データソースの作成。

既存コードをそのまま動かしたいなら**Notion-Version: 2022-06-28を固定**すれば当面動く。ただし新機能(マルチソース)は使えない。

2.2 最小スニペット — データベースに行を追加

import { Client } from '@notionhq/client'
const notion = new Client({
  auth: process.env.NOTION_TOKEN!,
  notionVersion: '2025-09-03',
})

await notion.pages.create({
  parent: { data_source_id: process.env.NOTION_CHANGELOG_DATA_SOURCE_ID! },
  properties: {
    Title: { title: [{ type: 'text', text: { content: title } }] },
    Date: { date: { start: dateIso } },
    Summary: { rich_text: [{ type: 'text', text: { content: summary } }] },
    'Linear Issue': { url: linearIssueUrl },
    PRs: { rich_text: [{ type: 'text', text: { content: prUrls.join('\n') } }] },
  },
})

ポイント。

  • parentdata_source_idであってdatabase_idではない。旧コードはdatabase_idだったが互換モードでまだ動く。
  • propertiesのキーはデータベースのカラム名そのまま。大文字小文字とスペースも含めて。
  • Titleカラムの名前は毎回「Title」とは限らない。データベースごとに違うので、本番では先にGET /v1/data_sources/:idでスキーマを取ってtitleカラム名を引くのが安全。

2.3 データソースIDの見つけ方

NotionのUIはデータベースIDは表示するがデータソースIDは表示しない。シェル一発で。

curl -X GET "https://api.notion.com/v1/databases/$DATABASE_ID" \
  -H "Authorization: Bearer $NOTION_TOKEN" \
  -H "Notion-Version: 2025-09-03"

レスポンスのdata_sources配列の最初の要素がidを持つ。シングルソース・データベースなら1つだけ入っている。

2.4 レート制限

Notionは平均1秒3リクエストを保証する。短時間のバーストは許されるが、持続するとrate_limitedエラー(status: 429)が返る。対応。

  • Retry-Afterヘッダを読んでその時間だけスリープ。
  • 同時実行数はp-limitなどで3以下にキャップ。
  • 1つのワークフローでページ100個を作るならキューに入れて流す。

3章 · Slack API — Bolt 4 + Socket Modeハンズオン

3.1 どのSDKを使うか

Node/Bunで事実上の標準は@slack/bolt。2025年にv4が出て、Web API・Events API・Socket Mode・インタラクティブコンポーネント(ボタン、モーダル)を1つのオブジェクトで束ねる。

このワークフローでSlackがやることは2つ。

  1. Linear issueクローズ通知をチャンネルに投稿 — chat.postMessage
  2. (オプション) ユーザーが「changelog記載をスキップ」ボタンを押したらNotion書き込みをキャンセル — Block Kitアクション + Events API。

3.2 最小スタート — Incoming Webhook

送信だけならSDKすら要らない。

// slack-webhook.ts
const WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL!

export async function postToSlack(text: string) {
  const r = await fetch(WEBHOOK_URL, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ text }),
  })
  if (!r.ok) {
    throw new Error(`Slack webhook failed: ${r.status} ${await r.text()}`)
  }
}

Webhookはインストール時に1チャンネルに紐付く。動的にチャンネルを切り替えたいならBotトークン + chat.postMessageへ。

3.3 Bot Token + chat.postMessage

Block Kitでヘッダ・セクション・context・アクションボタンを束ねたメッセージの骨格。

import { WebClient } from '@slack/web-api'
const slack = new WebClient(process.env.SLACK_BOT_TOKEN!)

await slack.chat.postMessage({
  channel,
  text: `${issueTitle} closed`, // 通知プレビュー用
  blocks: [
    { type: 'header', text: { type: 'plain_text', text: issueTitle } },
    { type: 'section', text: { type: 'mrkdwn', text: summary } },
    {
      type: 'actions',
      elements: [{
        type: 'button',
        text: { type: 'plain_text', text: 'Skip changelog' },
        style: 'danger',
        action_id: 'skip_changelog',
        value: issueUrl,
      }],
    },
  ],
})

textは通知プレビューに使われるので空にしない。フルバージョンは6章で。

3.4 Socket Modeでインタラクションを受ける

Skip changelogボタンを処理するにはイベントの受信が要る。社内ツールは社内ファイアウォール内に置かれることが多く、Socket Modeが楽。

import { App } from '@slack/bolt'
const app = new App({
  token: process.env.SLACK_BOT_TOKEN!,
  appToken: process.env.SLACK_APP_TOKEN!, // xapp-...
  socketMode: true,
})

app.action('skip_changelog', async ({ ack, body }) => {
  await ack()
  const issueUrl = (body as any).actions[0].value
  await markSkip(issueUrl) // KVにマーク、Notion書き込み直前で参照
})

await app.start()

Socket Modeの良さは3つ — インバウンドポートを開けない、ngrok/cloudflaredトンネルが不要、ローカルと本番のコードが同じ。弱点は1つ: 水平スケーリングが面倒。インスタンス2つで同じイベントが両方に届く。社内ツール規模では問題にならない。

3.5 Slackのレート制限

  • chat.postMessageはチャンネル単位のTier 4 — 分100件、ほぼ気にしなくていい。
  • users.listなどのグローバルTierは分50件なので、頻繁に呼ぶならキャッシュ。
  • 429レスポンスにはRetry-Afterヘッダが付く。そのままスリープ後にリトライ。

4章 · Linear GraphQL — SDKとWebhook

4.1 SDKが正解

Linearは公式TypeScript SDK @linear/sdkをきちんとメンテしている。4.xが安定版。GraphQLスキーマの上に型付きメソッドを乗せていて、手書きでクエリを書く必要がない。

import { LinearClient } from '@linear/sdk'
const linear = new LinearClient({ apiKey: process.env.LINEAR_API_KEY! })

const issue = await linear.issue(issueId)
const [comments, attachments] = await Promise.all([
  issue.comments(),
  issue.attachments(),
])
// issue.identifier === 'ENG-1234', issue.url, issue.title, ...
// PRはattachmentsに入る
const prUrls = attachments.nodes
  .filter((a) => a.url.startsWith('https://github.com/') && a.url.includes('/pull/'))
  .map((a) => a.url)

4.2 Webhook — 署名検証は絶対に省かない

LinearのWebhookはLinear-SignatureヘッダでHMAC-SHA256署名を一緒に送ってくる。必ず検証する — でないと誰でも偽ペイロードで自動化をトリガできる。

import { createHmac, timingSafeEqual } from 'node:crypto'
function verifySig(raw: string, sig: string, secret: string) {
  const expected = createHmac('sha256', secret).update(raw).digest('hex')
  return expected.length === sig.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
}

Bunでの受け取りパターンは6章のserve({...})そのまま。鉄則は200を素早く返すこと — Linearは5秒を超えるとリトライし、失敗が積み重なるとWebhookを無効化する。重い処理はqueueMicrotaskで非同期分離。

4.3 「Doneに変わった瞬間」をどう捕まえるか

Webhookのペイロードはアクションとデータをくれるが「前の状態」は教えてくれない。2つの方法。

  1. ローカル状態キャッシュ — issueごとに最後に観測した状態をKVに保存して比較。最も正確。
  2. 状態IDによる分岐 — ペイロードのdata.state.idが「Done」状態のIDと一致するかだけ見る。難点: 「DoneからIn Progressへの戻り」のような後退を見逃す。

ハンズオンでは2を使う。Done状態のIDはワークスペースごとに固定なので、一度引いておけば終わり。

const DONE_STATE_IDS = new Set([process.env.LINEAR_DONE_STATE_ID!])

function isClosed(event: any) {
  return event.type === 'Issue' && DONE_STATE_IDS.has(event.data?.state?.id)
}

4.4 Linearのレート制限

  • GraphQL APIは1時間あたり1500複雑度ポイントモデル。単純クエリは1点、100件ページネーションのクエリは10点。
  • レスポンスヘッダX-RateLimit-Requests-Remainingを見る。
  • SDKは429を受けたら1回だけ自動リトライしてくれる。冪等性は自分で担保。

5章 · LLMグルーパターン — どこで挟み、どこで抜くか

5.1 LLMが本当に得意なこと

3つに集約される。

  1. 要約 — PR7本 + コミットメッセージ30件をchangelog1行に。
  2. 分類 — 「このissueはbug/feature/chore/security のどれ?」みたいな短い分岐。
  3. ドラフト — Slackメッセージの見出しと本文を一貫したトーンで書く。

それ以外はだいたい正規表現か単純マッピングのほうがいい。LLMを差すとコスト・遅延・非決定性を抱え込む。

5.2 要約呼び出し — Claude Sonnet 4.5の例

要約呼び出しの骨格。

import Anthropic from '@anthropic-ai/sdk'
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! })

const resp = await anthropic.messages.create({
  model: 'claude-sonnet-4-5',
  max_tokens: 400,
  messages: [{ role: 'user', content: prompt }],
})
const text = resp.content
  .filter((b) => b.type === 'text')
  .map((b) => (b as any).text)
  .join('')
const json = text.match(/\{[\s\S]*\}/)?.[0]

コスト感覚: Sonnet 4.5は入力100万トークンで約3ドル、出力で15ドル。1呼び出しあたり0.002〜0.01ドル。日100件でも月30ドルは超えない。フルプロンプトとフォールバックは6章で。

5.3 モデル選択

  • 短い分類分岐: Haikuのような小さいモデル、またはGPT-4o-mini。速くて安い。
  • 要約・ドラフト: SonnetまたはGPT-4o。品質の差ははっきり出る。
  • 推論: 本当に必要な場合のみreasoningモード。とはいえワークフロー自動化で推論が必要になる場面はまれ。

5.4 LLMを差さない場所

  • 「issueが閉じたら通知を送る」みたいな明確な分岐。
  • 状態変換(openを「Open」に大文字化など)。
  • ルーティング(このチャンネル vs あのチャンネル) — 静的マッピングテーブルで十分。

決定的なマッピングのところにLLMを差すとときどき間違えるようになり、デバッグが地獄になる。

5.5 出力検証 — JSONを信用しない

LLMはJSONの前後におしゃべりを足したり必須フィールドを落としたりする。必ずZodなどスキーマでsafeParseし、失敗時は安全なフォールバック(例: カテゴリはchore、要約はissueタイトル)に倒す。6章のフルスクリプトにそのパターンが含まれている。


6章 · フルスクリプト — Linearクローズ → Slack → Notion

ここまでの部品を1本にまとめる。Bun単一プロセス、おおよそ200行。

// server.ts
import { serve } from 'bun'
import { LinearClient } from '@linear/sdk'
import { WebClient } from '@slack/web-api'
import { Client as NotionClient } from '@notionhq/client'
import { createHmac, timingSafeEqual } from 'node:crypto'
import Anthropic from '@anthropic-ai/sdk'
import { z } from 'zod'

const env = z
  .object({
    LINEAR_API_KEY: z.string(),
    LINEAR_WEBHOOK_SECRET: z.string(),
    LINEAR_DONE_STATE_ID: z.string(),
    SLACK_BOT_TOKEN: z.string(),
    SLACK_CHANNEL: z.string(),
    NOTION_TOKEN: z.string(),
    NOTION_CHANGELOG_DATA_SOURCE_ID: z.string(),
    ANTHROPIC_API_KEY: z.string(),
  })
  .parse(process.env)

const linear = new LinearClient({ apiKey: env.LINEAR_API_KEY })
const slack = new WebClient(env.SLACK_BOT_TOKEN)
const notion = new NotionClient({ auth: env.NOTION_TOKEN, notionVersion: '2025-09-03' })
const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY })

function verifySig(raw: string, sig: string) {
  const expected = createHmac('sha256', env.LINEAR_WEBHOOK_SECRET).update(raw).digest('hex')
  return expected.length === sig.length && timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
}

async function summarize(args: {
  issueTitle: string
  description: string
  prTitles: string[]
}) {
  const prompt = `Summarize this Linear issue closure as one sentence for a changelog. Also classify.

Title: ${args.issueTitle}
Description:
${args.description.slice(0, 2000)}
PRs:
${args.prTitles.map((t) => '- ' + t).join('\n')}

Output JSON only: {"summary":"...","category":"bug|feature|chore|security"}`
  const resp = await anthropic.messages.create({
    model: 'claude-sonnet-4-5',
    max_tokens: 300,
    messages: [{ role: 'user', content: prompt }],
  })
  const text = resp.content
    .filter((b) => b.type === 'text')
    .map((b) => (b as any).text)
    .join('')
  const m = text.match(/\{[\s\S]*\}/)
  const schema = z.object({
    summary: z.string().min(3).max(280),
    category: z.enum(['bug', 'feature', 'chore', 'security']),
  })
  const parsed = m ? schema.safeParse(JSON.parse(m[0])) : null
  if (!parsed || !parsed.success) {
    return { summary: args.issueTitle, category: 'chore' as const }
  }
  return parsed.data
}

async function handleIssueClosed(event: any) {
  const issueId = event.data.id
  const issue = await linear.issue(issueId)
  const attachments = await issue.attachments()
  const prUrls = attachments.nodes
    .filter((a) => a.url.includes('github.com') && a.url.includes('/pull/'))
    .map((a) => a.url)
  const prTitles = attachments.nodes
    .filter((a) => prUrls.includes(a.url))
    .map((a) => a.title || a.url)

  const { summary, category } = await summarize({
    issueTitle: issue.title,
    description: issue.description ?? '',
    prTitles,
  })

  await slack.chat.postMessage({
    channel: env.SLACK_CHANNEL,
    text: `${issue.identifier} closed: ${issue.title}`,
    blocks: [
      {
        type: 'header',
        text: { type: 'plain_text', text: `${issue.identifier} closed` },
      },
      { type: 'section', text: { type: 'mrkdwn', text: `*${issue.title}*\n${summary}` } },
      {
        type: 'context',
        elements: [
          { type: 'mrkdwn', text: `Category: ${category}` },
          { type: 'mrkdwn', text: `<${issue.url}|Linear>` },
          ...prUrls.map((u) => ({ type: 'mrkdwn' as const, text: `<${u}|PR>` })),
        ],
      },
    ],
  })

  await notion.pages.create({
    parent: { data_source_id: env.NOTION_CHANGELOG_DATA_SOURCE_ID },
    properties: {
      Title: { title: [{ type: 'text', text: { content: issue.title } }] },
      Date: { date: { start: new Date().toISOString().slice(0, 10) } },
      Summary: { rich_text: [{ type: 'text', text: { content: summary } }] },
      Category: { select: { name: category } },
      'Linear Issue': { url: issue.url },
      PRs: { rich_text: [{ type: 'text', text: { content: prUrls.join('\n') } }] },
    },
  })
}

const DONE_STATE_IDS = new Set([env.LINEAR_DONE_STATE_ID])

serve({
  port: Number(process.env.PORT ?? 3000),
  async fetch(req) {
    if (req.method !== 'POST') return new Response('only POST', { status: 405 })
    const raw = await req.text()
    const sig = req.headers.get('linear-signature') ?? ''
    if (!verifySig(raw, sig)) return new Response('bad sig', { status: 401 })
    const event = JSON.parse(raw)
    const closed =
      event.type === 'Issue' &&
      event.action === 'update' &&
      DONE_STATE_IDS.has(event.data?.state?.id)
    if (closed) {
      queueMicrotask(() => handleIssueClosed(event).catch((e) => console.error('handler failed', e)))
    }
    return new Response('ok')
  },
})

これ一本でワークフロー全体が動く。依存関係は以下。

bun add @anthropic-ai/sdk @linear/sdk @notionhq/client @slack/web-api zod

Fly.ioへのデプロイ。

fly launch --no-deploy
fly secrets set LINEAR_API_KEY=... LINEAR_WEBHOOK_SECRET=... # ...
fly deploy

7章 · シークレット管理 — 1Password・Doppler・Vercel

7.1 絶対にやらないこと

  • .envをgitにコミット。事故の30%はここで起きる。
  • Slackチャンネルにトークンを貼る。保存期間が90日以上ならほぼ永続。
  • 本番トークンをローカル開発に使う。1回のミスで大事故。

7.2 1Password CLI — 個人開発者にはコスパ◎

.env.tplLINEAR_API_KEY=op://dev-secrets/linear-prod/passwordのような参照だけを書いてop run --env-file=.env.tpl -- bun run server.tsで実行すれば、平文シークレットがディスクに落ちることが絶対にない。

7.3 Doppler — チームで共有するなら

DopplerはホスティングされたシークレットマネージャSaaS。ワークスペース単位でシークレットを置き、doppler run -- bun run server.tsで注入。CI/CDでも同じインターフェースが使えるのが大きい。5人以下なら無料枠で十分。

7.4 Vercel/Cloudflareの環境変数

サーバーレスデプロイならコンソールに入れるのが最もシンプル。難点は環境ごとの手動同期。CIで自動注入したいならvercel env pullを一度仕込む。

7.5 シークレットローテーション

3ヶ月ごと。4種のトークンはほぼ同じ手順 — 新規発行 → 環境変数差し替え → 旧トークン無効化。ただしNotionだけは回転と同時に旧トークンが失効するので、無停止を狙うなら2インテグレーションを一時的に並走させる。自動化しないと人間がやる — 人間は毎回忘れる。


8章 · 運用 — レート制限・リトライ・観測

8.1 リトライ方針

3 APIともに一時的な5xxと429を返す。標準ポリシーは3行。

  • 5xx: 指数バックオフ、最大3回。
  • 429: Retry-Afterヘッダを尊重。無ければ1秒 → 2秒 → 4秒。
  • 4xx(429以外): リトライ禁止。 自分のコードのバグ。

小さなヘルパーはforループ + try/catchstatusを見て分岐する形で十分。一度書いてすべてのAPI呼び出しを包めば終わり。

8.2 冪等性

Webhookはリトライされる。同じissueクローズイベントが2回届けばNotionに行が2つできる。防御は2つ。

  • イベントごとにevent.idをKVに記録し、見たことがあるなら無視。
  • Notion行を作る前に「同じissue URLの行が既にあるか」をクエリ。

基本方針: 常に重複ありと仮定する

8.3 観測とアラート

ログはconsole.log(JSON.stringify({...}))の1行で構造化する。Fly.ioのfly logsでgrep可能。1時間投資してOpenTelemetryを入れるとさらに便利 — Honeycombの無料枠で十分。

ワークフローが壊れて誰も気づかないまま1週間が過ぎる、というのが最も多い事故パターン。ハンドラの最上位でtry/catchして、失敗時に#alerts-devのような運用チャンネルに1行流す。:rotating_light: workflow failed: ${msg}程度で十分。


9章 · 正直な比較 — Zapier/n8nとのトレードオフ

9.1 自分で書いたほうがいいとき

  • ロジックが単純じゃない — 5段の分岐、条件マッチング、LLM呼び出し、外部KV。これをiPaaSのGUIで作るとデバッグ地獄。
  • タスクコストが膨らむ — Zapierはタスク課金なので、数千件のワークフローだと月コストが急騰。自前コードはインフラ5ドルで済む。
  • 会社のポリシーでSaaSにシークレットを入れられない — ZapierがOAuthトークンを握っているのがコンプラ的にNG、ということはある。
  • デプロイ・ロールバックを速くしたい — コードならgit revert。GUIは変更履歴が弱い。

9.2 Zapier/n8nのほうがいいとき

  • ワークフロー数が多く種類も多様 — 30個の自動化を30本のスクリプトで回すと運用が辛い。n8nで1箇所に集めるほうがマシ。
  • 非エンジニアも触る必要がある — マーケがトリガー条件を変えるならGUIが必須。
  • 新SaaS接続が頻繁 — Zapierの9,000+統合は強力。自前で書くならSaaSごとにSDKと認証を覚える必要がある。
  • メンテに時間を割けない — 自前コードは自分が保守する。一人チームでは負担。

9.3 コストシミュレーション — 1日100ワークフロー

項目Zapiern8nセルフホスト自前コード
ホスティング0(SaaS)月5ドル(Fly.io)月5ドル(Fly.io)
タスクコスト3,000件 × 0.02ドル = 月60ドル00
LLM API含まず含まず月30ドル(Claude)
開発時間2時間6時間10時間
月メンテ0.5時間1時間0.5時間
月合計60ドル+5ドル+35ドル+

LLMを抜けば自前コードは月5ドル。時間を一度払えば限界費用はほぼゼロ。

9.4 推奨判断

  1. 自動化が5個以下 → 自分で書く。学べる量が違う。
  2. 5〜30個 → n8nセルフホスト。統合カタログを活用。
  3. 30個以上で非エンジニアも触る → ZapierまたはMake。
  4. コンプラ厳しくSaaSにトークンを渡せない → 自前コード + 社内シークレットマネージャ。

エピローグ — チェックリストとアンチパターン

ハンズオンチェックリスト

  • シークレットが絶対にgitに入っていないか
  • LinearのWebhook署名を検証しているか
  • 冪等性キーをKVに保存しているか
  • LLM出力をZodスキーマで検証しているか
  • 失敗時にアラートチャンネルへ通知が飛ぶか
  • 4xxではリトライしないか(それは自分のバグ)
  • Notionインテグレーションを対象データベースに明示共有したか
  • SlackボットがチャンネルにインバイトされているかPart(でないとnot_in_channel)
  • 環境変数6個すべて本番に設定されているか
  • ログは構造化されているか

アンチパターン

  • すべての分岐にLLMを差す — 非決定性が積み上がる。単純マッピングならLLMを抜く。
  • Notion-Versionヘッダを付けない — 明示しないと最新版にルーティングされ、マイグレーション時に壊れる。常に固定。
  • Webhook内で即重い処理 — 5秒SLOに収まらない処理はキューへ。さもないとWebhookが切られる。
  • リトライの暴走 — 4xxをリトライすると自分のコードバグを外部APIに押し付けることになる。アラート出して停止。
  • チャンネルID・データベースIDをハードコード — 環境変数に。引っ越しのたびにコードをいじらないこと。
  • Socket Modeを2インスタンス — 同じイベントを2回処理する。社内ツール規模では単一インスタンスが正解。
  • JSON出力を信用 — LLMはおしゃべりを足す。常にパース + スキーマ検証。
  • シークレットローテをしない — 3ヶ月ごと。カレンダーに入れない限り誰もやらない。
  • PIIをそのままLLMに — 顧客メールや個人情報をプロンプトに入れない。マスクするか社内モデル。
  • エラーを握りつぶす — 空のtry/catchは運用の敵。最低限、構造化ログ。

次回予告

次回はOpenTelemetry・Tempo・Grafanaで社内自動化にトレーシングを付けるハンズオン。この200行スクリプトに5分投資して分散トレーシングを入れれば、「なぜ昨日は5秒で済んだのに今日は50秒かかるの?」に答えられるようになる。Anthropic・Slack・Linear・Notion各々の呼び出しレイテンシを1画面で見渡せる。


参考 / References