プロローグ — 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/sdk4.x、Webhook署名、プロジェクトとメンバーシップのモデル。 - LLMグルー — Claude/GPTを中間に挟んで分類・要約・ドラフトを任せる。単純なマッピングなら過剰だが、「PR7本をchangelog1行に圧縮」はLLMの得意分野。
この記事は先週公開したZapier vs n8n比較記事とペアだが、向こうはスナップショット比較で、こちらは実際に組むハンズオンである。
流れ。
- 事前準備 — トークン・シークレット・トリガーモデル決定
- Notion API 2026 — Data Sources APIの衝撃
- Slack API — Bolt 4 + Socket Modeハンズオン
- Linear GraphQL — SDKとWebhook
- LLMグルーパターン — どこで挟み、どこで抜くか
- フルスクリプト — Linearクローズ → Slack → Notion
- シークレット管理 — 1Password CLI・Doppler・Vercel環境変数
- 運用 — レート制限・リトライ・観測
- 正直な比較 — Zapier/n8nとのトレードオフ
- アンチパターン
読み終わる頃には、自チームの2〜3個の小さな自動化を自分で書こうという気になっているはず。
1章 · 事前準備 — トークン・シークレット・トリガーモデル
1.1 トークン種別 一行まとめ
| プラットフォーム | トークン名 | 権限モデル | 発行場所 |
|---|---|---|---|
| Notion | Internal Integration Secret | Capabilityトグル + ページ/データベースの明示的共有 | notion.so/profile/integrations |
| Slack | Bot User OAuth Token (xoxb-) + App Token (xapp-) | スコープ単位 (chat:write、channels:historyなど) | api.slack.com/apps |
| Linear | Personal API KeyまたはOAuth | ワークスペース全体またはユーザー単位 | linear.app/settings/account/security |
| Anthropic | API Key | ワークスペース + 使用量上限 | console.anthropic.com |
最大の落とし穴: Notionのインテグレーションは明示的に共有されていないページにはアクセスできない。インテグレーションを作成するだけでなく、対象のページ/データベースで「インテグレーションを追加」メニューを押す必要がある。新人がいちばん詰まる箇所。
1.2 トリガーモデル — Webhook vs ポーリング vs イベント購読
3プラットフォームとも3方式すべてに対応しているが、推奨モデルは違う。
- Notion: 2024年からWebhookが正式サポート。
database.content_updated、page.created、comment.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') } }] },
},
})
ポイント。
parentはdata_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つ。
- Linear issueクローズ通知をチャンネルに投稿 —
chat.postMessage。 - (オプション) ユーザーが「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つの方法。
- ローカル状態キャッシュ — issueごとに最後に観測した状態をKVに保存して比較。最も正確。
- 状態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つに集約される。
- 要約 — PR7本 + コミットメッセージ30件をchangelog1行に。
- 分類 — 「このissueはbug/feature/chore/security のどれ?」みたいな短い分岐。
- ドラフト — 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.tplにLINEAR_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/catchでstatusを見て分岐する形で十分。一度書いてすべての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ワークフロー
| 項目 | Zapier | n8nセルフホスト | 自前コード |
|---|---|---|---|
| ホスティング | 0(SaaS) | 月5ドル(Fly.io) | 月5ドル(Fly.io) |
| タスクコスト | 3,000件 × 0.02ドル = 月60ドル | 0 | 0 |
| LLM API | 含まず | 含まず | 月30ドル(Claude) |
| 開発時間 | 2時間 | 6時間 | 10時間 |
| 月メンテ | 0.5時間 | 1時間 | 0.5時間 |
| 月合計 | 60ドル+ | 5ドル+ | 35ドル+ |
LLMを抜けば自前コードは月5ドル。時間を一度払えば限界費用はほぼゼロ。
9.4 推奨判断
- 自動化が5個以下 → 自分で書く。学べる量が違う。
- 5〜30個 → n8nセルフホスト。統合カタログを活用。
- 30個以上で非エンジニアも触る → ZapierまたはMake。
- コンプラ厳しく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
- Notion API — Upgrade Guide (2025-09-03)
- Notion API — Working with databases (data sources)
- Notion API — Rate limits
- Slack Bolt for JavaScript
- Slack — Socket Mode
- Slack — chat.postMessage
- Slack — Rate limits
- Linear — GraphQL API docs
- Linear — Webhooks
- Linear — TypeScript SDK (
@linear/sdk) - Anthropic — Messages API
- Anthropic — Pricing
- 1Password CLI
- Doppler — Secrets management
- Vercel — Environment variables
- Fly.io — Deploying Bun apps
- Zod — TypeScript-first schema validation
현재 단락 (1/384)
小さな社内ツールがあった。**LinearでissueがDoneになった瞬間**、GitHubのPRとコミットメッセージを集めて**Slackチャンネルに要約通知**を流し、**Notionのchan...