Split View: Notion·Slack·Linear API를 LLM으로 묶는 워크플로 자동화 핸즈온 — 2026년판 사내툴 글루 코드 가이드
Notion·Slack·Linear API를 LLM으로 묶는 워크플로 자동화 핸즈온 — 2026년판 사내툴 글루 코드 가이드
프롤로그 — Zapier 청구서가 350달러를 찍은 날
작은 사내툴이 하나 있다. Linear에서 이슈가 Done으로 바뀌면, GitHub PR과 커밋 메시지를 모아서 Slack 채널에 요약 알림을 보내고, Notion 체인지로그 데이터베이스에 한 줄을 추가한다. 처음엔 Zapier 5단계 Zap이었다. 한 달 청구서가 350달러를 찍었을 때 결심했다 — 이건 200줄짜리 스크립트로 충분하다.
이 글은 그 200줄을 처음부터 끝까지 짜본 핸즈온이다. 같이 묶을 친구들:
- Notion API — 2025년 8월에 공개된 Data Sources API로 데이터 모델이 바뀌었다. 데이터베이스가 더 이상 "단일 테이블"이 아니다.
- Slack API — Bolt 4 + Socket Mode +
chat.postMessage+ 인커밍 웹훅. 어느 쪽이 언제 옳은지. - Linear GraphQL API —
@linear/sdk4.x, 웹훅 시그니처, 멤버십과 프로젝트 모델. - LLM 글루 — Claude/GPT를 중간에 끼워 넣어 분류·요약·드래프트를 시킨다. 단순 매핑이면 LLM은 사치지만, "PR 7개를 변경로그 한 줄로 압축"은 LLM이 더 잘한다.
이 글은 지난주 Zapier·n8n 비교 글과 짝이지만, 그건 스냅숏 비교였고 이건 직접 짜는 핸즈온이다.
흐름:
- 사전 준비 — 토큰·시크릿·트리거 모델 결정
- Notion API 2026 — Data Sources API의 충격
- Slack API — Bolt 4 + Socket Mode 핸즈온
- Linear GraphQL — SDK와 웹훅
- LLM 글루 패턴 — 언제 끼워 넣고 언제 빼는가
- 풀 스크립트 — Linear 종료 → Slack → Notion
- 시크릿 관리 — 1Password CLI · Doppler · Vercel 환경 변수
- 운영 — 레이트리밋·재시도·관찰
- 정직한 비교 — Zapier/n8n과의 트레이드오프
- 안티패턴
다 읽으면 본인 팀의 두세 개 자잘한 자동화는 직접 짤 마음이 들 것이다.
1장 · 사전 준비 — 토큰·시크릿·트리거 모델
1.1 토큰 종류 한 줄 정리
| 플랫폼 | 토큰 이름 | 권한 모델 | 발급 위치 |
|---|---|---|---|
| Notion | Internal Integration Secret | Capability 토글 + 페이지/데이터베이스 명시 공유 | notion.so/profile/integrations |
| Slack | Bot User OAuth Token (xoxb-) + App Token (xapp-) | Scopes 단위 (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 트리거 모델 — 웹훅 vs 폴링 vs 이벤트 구독
세 플랫폼 모두 셋 다 지원하지만 권장 모델이 다르다.
- Notion: 2024년부터 웹훅을 정식 지원한다.
database.content_updated,page.created,comment.created같은 이벤트 타입을 등록할 수 있다. 그래도 여전히 폴링이 흔하다 — Notion의 변경 알림은 부분적이라, "이 페이지의 모든 변경"을 따라가려면 결국 페이지 자체를 다시 가져와 비교해야 한다. - Slack: Events API + 웹훅, 그리고 방화벽 안에서 돌릴 때를 위한 Socket Mode(WebSocket)가 있다. 로컬 개발과 작은 사내툴에는 Socket Mode가 한참 편하다.
- Linear: 웹훅이 1급 시민이다. 이슈/프로젝트/코멘트/사이클 등 거의 모든 변경에 웹훅을 걸 수 있다. GraphQL 쿼리로 폴링하는 경우는 거의 없다.
이 핸즈온의 흐름은 Linear 웹훅 → Slack 알림 → Notion 행 추가이므로, "Linear 웹훅을 받아주는 작은 서버"가 시작점이다.
1.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에도 큰 변화를 줬다. 한 데이터베이스 안에 여러 개의 데이터 소스를 둘 수 있게 됐고, API도 이걸 반영해야 했다.
- 이전(2024년 9월 버전): 데이터베이스 = 단일 테이블.
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가 데이터 소스 ID다. 단일 소스 데이터베이스라면 하나만 들어 있다.
2.4 레이트리밋
Notion은 초당 3건의 평균 요청을 보장한다. 버스트는 단기간 허용되지만 지속되면 rate_limited 에러(status: 429)가 떨어진다. 대응:
Retry-After헤더를 읽어 그만큼 잠든다.- 동시 호출은
p-limit등으로 3 이하로 캡한다. - 한 워크플로에서 페이지 100개를 만드는 식이면 차라리 큐에 넣고 처리한다.
3장 · Slack API — Bolt 4 + Socket Mode 핸즈온
3.1 어떤 SDK를 쓸까
Node/Bun에서 사실상 표준은 @slack/bolt다. 2025년에 v4가 나왔고, Web API · Events API · Socket Mode · 인터랙티브 컴포넌트(buttons, modals)를 한 객체로 묶는다.
이번 워크플로에서 Slack의 역할은 두 가지다.
- Linear 이슈 종료 알림을 채널에 올린다 —
chat.postMessage. - (옵션) 사용자가 "체인지로그에 안 올림" 버튼을 누르면 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()}`)
}
}
웹훅은 앱 설치 시 채널 하나에 묶인다. 채널을 동적으로 바꿀 거면 봇 토큰 + 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가 좋은 이유는 셋: 인바운드 포트를 안 연다, ngrok/cloudflared 터널 불요, 로컬과 운영 코드가 같다. 단점은 한 가지 — 수평 확장이 까다롭다. 인스턴스 2개면 동일 이벤트가 양쪽에 도착한다. 사내툴 규모에서는 문제 안 된다.
3.5 Slack 레이트리밋
chat.postMessage는 채널 단위 Tier 4 — 분당 100건, 사실상 신경 안 써도 된다.users.list같은 글로벌 Tier는 분당 50건이라 자주 부른다면 캐시한다.- 429 응답에
Retry-After헤더가 같이 온다. 그대로 sleep 후 재시도.
4장 · Linear GraphQL — SDK와 웹훅
4.1 SDK가 정답이다
Linear는 공식 TypeScript SDK @linear/sdk를 잘 유지한다. 4.x가 안정 버전. GraphQL 스키마 위에 typed 메서드를 얹어줘서 손으로 쿼리를 쓰지 않아도 된다.
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 웹훅 — 시그니처 검증을 잊지 마라
Linear 웹훅은 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초 넘으면 재시도하고, 며칠 안에 실패가 누적되면 웹훅을 비활성화한다. 무거운 처리는 queueMicrotask로 비동기 분리.
4.3 "Done으로 바뀐 순간"을 어떻게 잡나
웹훅 페이로드는 액션과 데이터만 주지, "이전 상태"는 안 준다. 두 가지 방법:
- 로컬 상태 캐시 — 이슈별 마지막으로 본 상태를 KV에 저장하고 비교한다. 가장 정확.
- 워크플로 상태 ID로 분기 — 페이로드의
data.state.id가 "Done" 상태의 ID와 같은지만 본다. 단점: "Done에서 다시 In Progress로" 같은 회귀를 못 잡는다.
핸즈온에서는 2를 쓴다. Done의 state 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는 시간당 1500 복잡도 포인트라는 모델이다. 단순 쿼리는 1점, 페이지네이션으로 100개씩 가져오면 10점.
- 응답 헤더
X-RateLimit-Requests-Remaining을 본다. - SDK는 429를 받으면 자동으로 한 번 재시도해준다. 그래도 멱등성은 본인 책임.
5장 · LLM 글루 패턴 — 언제 끼우고 언제 빼는가
5.1 LLM이 진짜로 더 잘하는 일
세 가지로 압축된다.
- 요약 — PR 7개 + 커밋 메시지 30개를 변경로그 한 줄로.
- 분류 — "이 이슈는 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달러, 출력 100만 토큰당 15달러 수준. 호출 한 번에 0.002~0.01달러. 하루 100건이라도 한 달에 30달러를 안 넘긴다. 풀 프롬프트와 폴백은 6장에서.
5.3 모델 선택 — 무엇을 쓸지
- 분류 같은 짧은 분기: Haiku 같은 작은 모델. 또는 GPT-4o-mini. 빠르고 싸다.
- 요약·드래프트: Sonnet 또는 GPT-4o. 품질이 눈에 띄게 좋다.
- 추론(decision): 적용 가능한 작업이라면 reasoning 모드. 다만 워크플로 자동화에서 추론이 필요한 경우는 드물다.
5.4 LLM을 끼우지 말아야 할 곳
- "이슈가 닫혔으면 알림 보낸다" 같은 분명한 분기.
- 상태 변환 (
open→ "Open" 같은 대문자화). - 라우팅(이 채널 vs 저 채널) — 단순 매핑 테이블이면 충분.
매핑이 정해진 곳에서 LLM을 쓰면 간헐적 실수가 들어와 디버깅이 골치 아파진다.
5.5 결과 검증 — JSON 출력은 신뢰하지 말 것
LLM은 가끔 JSON 앞뒤에 잡설을 붙이거나 필수 필드를 빼먹는다. 항상 Zod 같은 스키마로 safeParse하고, 실패 시엔 안전한 폴백(예: 카테고리는 chore, 요약은 issue 제목)으로 떨어진다. 6장 풀 스크립트에 그 패턴이 들어 있다.
6장 · 풀 스크립트 — Linear 종료 → Slack → Notion
지금까지의 조각들을 묶는다. 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를 깃에 커밋. 사고는 30%가 여기서 난다.- Slack 채널에 토큰을 붙여 넣기. 보존 기간 90일 이상이면 거의 영구.
- 프로덕션 토큰을 로컬 개발에 사용. 실수 한 번이 큰 사고.
7.2 1Password CLI — 1인 개발자에게 가성비
.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는 호스팅 시크릿 매니저. 워크스페이스 단위로 시크릿을 두고 doppler run -- bun run server.ts로 주입한다. CI/CD에서 같은 인터페이스를 쓸 수 있는 게 큰 장점. 5명 이하 팀이면 무료 티어로 충분.
7.4 Vercel/Cloudflare 환경 변수
서버리스 배포면 콘솔에 넣는 게 가장 단순. 단점은 환경별로 직접 동기화해야 한다는 점. CI에서 자동 주입하려면 vercel env pull을 한 번 박아둔다.
7.5 시크릿 회수 (rotation)
3개월에 한 번. 4개 토큰을 한 번에 돌리는 절차는 거의 동일하다 — 신규 발급 → 환경 변수 교체 → 구 토큰 비활성. 단 Notion은 회전 즉시 옛 토큰이 무효라 무중단을 원하면 두 통합을 잠시 병존시킨다. 자동화하지 않으면 인간이 한다 — 인간은 매번 잊는다.
8장 · 운영 — 레이트리밋·재시도·관찰
8.1 재시도 정책
세 API 모두 일시적 5xx와 429를 낸다. 표준 정책 세 줄.
- 5xx: 지수 백오프, 최대 3회.
- 429:
Retry-After헤더를 존중. 없으면 1초 → 2초 → 4초. - 4xx(429 제외): 재시도 금지. 그건 본인 코드 버그다.
작은 헬퍼는 for 루프 + try/catch에서 status를 보고 분기하는 형태면 충분하다. 한 번 짜서 모든 API 호출을 감싸면 끝.
8.2 멱등성
웹훅은 재시도된다. 같은 이슈 종료 이벤트가 두 번 오면 Notion에 행이 두 개 생긴다. 방어 두 줄.
- 이벤트마다
event.id를 KV에 기록하고, 본 적 있으면 무시. - Notion 행 생성 전에 "이미 같은 이슈 URL의 행이 있는지" 쿼리.
기본 정책: 항상 중복을 가정하라.
8.3 관찰과 알림
로그는 console.log(JSON.stringify({...})) 한 줄로 구조화한다. Fly.io의 fly logs에서 grep 가능. 한 시간 투자해 OpenTelemetry를 끼우면 더 좋다 — Honeycomb 무료 티어로 충분.
워크플로가 깨졌는데 아무도 모른 채 일주일이 가는 게 가장 흔한 사고. 핸들러 최상위에서 try/catch로 잡고, 실패 시 #alerts-dev 같은 운영 채널에 한 줄 보낸다. :rotating_light: workflow failed: ${msg} 정도면 충분.
9장 · 정직한 비교 — Zapier/n8n과의 트레이드오프
9.1 직접 짤 때가 더 나을 때
- 로직이 단순하지 않다 — 분기 5단계, 조건 매칭, LLM 호출, 외부 KV 등. iPaaS GUI에서 이걸 만들면 디버깅이 지옥이다.
- 태스크 비용이 폭발한다 — Zapier는 태스크당 과금이라 수천 건 워크플로면 월 비용이 가파르게 오른다. 직접 짠 코드는 인프라 비용 5달러로 끝난다.
- 시크릿이 회사 정책상 SaaS에 못 들어간다 — Zapier가 OAuth 토큰을 잡고 있다는 사실이 컴플라이언스 사유로 거절될 수 있다.
- 빨리 배포·롤백이 필요하다 — 코드면 그냥 git revert. GUI는 변경 이력이 약하다.
9.2 Zapier/n8n이 더 나을 때
- 워크플로 수가 많고 종류가 다양하다 — 30개 자동화를 30개 스크립트로 굴리면 운영이 어렵다. n8n 하나로 묶는 게 낫다.
- 비개발자도 손대야 한다 — 마케팅 팀이 트리거 컨디션을 바꾸려면 GUI가 필수.
- 새 SaaS 연결이 잦다 — Zapier의 9,000개+ 통합은 강력하다. 직접 짜려면 SaaS마다 SDK를 익히고 인증을 처리해야 한다.
- 유지보수에 시간을 못 쓴다 — 직접 짠 코드는 본인이 유지해야 한다. 한 명짜리 팀에서는 짐.
9.3 비용 시뮬레이션 — 하루 100건 워크플로
| 항목 | Zapier | n8n self-hosted | 직접 짠 코드 |
|---|---|---|---|
| 호스팅 | 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달러. 시간만 한 번 쓰면 비용은 거의 0.
9.4 권장 의사결정
- 자동화가 5개 이하 → 직접 짠다. 더 빨리 배운다.
- 5~30개 → n8n 셀프호스팅. 통합 카탈로그를 활용.
- 30개 이상, 비개발자 손이 필요 → Zapier 또는 Make.
- 컴플라이언스 빡세고 SaaS 토큰 못 줌 → 직접 짠다 + 사내 키 매니저.
에필로그 — 체크리스트와 안티패턴
핸즈온 체크리스트
- 시크릿이 깃에 절대 들어가지 않는가
- Linear 웹훅 시그니처를 검증하는가
- 멱등성 키를 KV에 저장하는가
- LLM 출력은 Zod 스키마로 검증하는가
- 실패 시 운영 채널에 알림이 가는가
- 재시도가 4xx에는 작동하지 않는가 (그건 본인 버그)
- Notion 통합을 대상 데이터베이스에 명시 공유했는가
- Slack 봇이 채널에 초대돼 있는가 (안 되면
not_in_channel에러) - 환경 변수 6개 모두 운영에 설정됐는가
- 로그는 구조화돼 있는가
안티패턴
- LLM을 모든 분기에 끼우기 — 비결정성이 누적된다. 단순 매핑이면 LLM은 빼라.
Notion-Version헤더 누락 — 명시 안 하면 최신 버전으로 라우팅되고 마이그레이션 시 깨진다. 항상 박아둔다.- 웹훅에서 즉시 무거운 일 — 5초 SLO 안에 못 끝낼 작업은 큐로 빼라. 안 그러면 웹훅이 끊긴다.
- 재시도 폭주 — 4xx에 재시도하면 본인 코드 버그를 외부 API에 떠넘긴다. 알림을 띄우고 멈춰라.
- 하드코딩된 채널 ID·데이터베이스 ID — 환경 변수로. 옮길 때 코드 안 고치게.
- Socket Mode 인스턴스 2개 — 같은 이벤트를 두 번 처리한다. 사내툴 규모에선 단일 인스턴스가 정답.
- JSON 출력 신뢰 — LLM은 가끔 잡설을 붙인다. 항상 파싱과 스키마 검증.
- 시크릿 회수를 안 함 — 3개월에 한 번. 캘린더에 박아두지 않으면 안 한다.
- PII 그대로 LLM에 — 고객 이메일이나 개인정보를 그대로 프롬프트에 넣지 마라. 마스킹 또는 사내 모델.
- 에러를 삼키기 — try/catch 빈 블록은 운영의 적. 최소한 구조화된 로그.
다음 글 예고
다음 글에서는 OpenTelemetry · Tempo · Grafana로 사내 자동화 트레이싱 붙이기 핸즈온을 다룬다. 이 200줄짜리 스크립트에 5분만 투자해 분산 트레이싱을 끼우면, "왜 어제는 5초 걸렸는데 오늘은 50초 걸리지?"에 답할 수 있게 된다. Anthropic·Slack·Linear·Notion 각각의 호출 지연을 한 화면에서 본다.
참고 / 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
Wiring Notion, Slack, and Linear with an LLM — A 2026 Hands-On Guide to Building Glue Code Instead of Paying Zapier
Prologue — The day Zapier hit 350 dollars
There was a small internal tool. When a Linear issue moved to Done, it gathered GitHub PRs and commit messages, posted a summarized Slack notification, and added a row to a Notion changelog database. It started as a 5-step Zapier Zap. The month the bill hit 350 dollars I drew the line — this is a 200-line script.
This post is that 200 lines, written from scratch. The friends we're wiring together:
- Notion API — the Data Sources API released in August 2025 changed the data model. A database is no longer a single table.
- Slack API — Bolt 4 + Socket Mode +
chat.postMessage+ incoming webhooks. When to use which. - Linear GraphQL API —
@linear/sdk4.x, webhook signatures, project and membership model. - LLM glue — drop Claude or GPT in the middle to classify, summarize, draft. For a flat mapping an LLM is overkill, but "compress 7 PRs into one changelog line" is exactly what LLMs do well.
This post is a companion to last week's Zapier vs n8n piece, but that one was a snapshot comparison; this is the hands-on build.
Flow:
- Prep — tokens, secrets, trigger model
- Notion API 2026 — the Data Sources API shock
- Slack API — Bolt 4 + Socket Mode hands-on
- Linear GraphQL — SDK and webhooks
- The LLM glue pattern — when to drop one in, when to leave it out
- The full script — Linear closure → Slack → Notion
- Secrets management — 1Password CLI, Doppler, Vercel env
- Operating it — rate limits, retries, observability
- The honest comparison — trade-offs versus Zapier and n8n
- Anti-patterns
By the end you should feel ready to write two or three of your team's small automations yourself.
Chapter 1 · Prep — tokens, secrets, trigger model
1.1 Token types in one line each
| Platform | Token name | Permission model | Where to issue |
|---|---|---|---|
| Notion | Internal Integration Secret | Capability toggles + explicit page/database share | notion.so/profile/integrations |
| Slack | Bot User OAuth Token (xoxb-) + App Token (xapp-) | Per scope (chat:write, channels:history, ...) | api.slack.com/apps |
| Linear | Personal API Key or OAuth | Workspace-wide or user-scoped | linear.app/settings/account/security |
| Anthropic | API Key | Workspace + usage limits | console.anthropic.com |
The big gotcha: a Notion integration cannot access pages that haven't been explicitly shared with it. Creating the integration isn't enough — you have to open each target page or database and add the integration from the menu. This is where new developers most often get stuck.
1.2 Trigger model — webhooks vs polling vs event subscriptions
All three platforms support all three, but each has a recommended path.
- Notion: webhooks have been GA since 2024. You can subscribe to events like
database.content_updated,page.created,comment.created. Even so, polling is still common — Notion's change notifications are partial, so following "every change on this page" usually means re-fetching and diffing anyway. - Slack: the Events API plus webhooks, and for the firewall-friendly case there's Socket Mode (WebSocket). For local development and small internal tools Socket Mode is dramatically easier.
- Linear: webhooks are first-class. Issues, projects, comments, cycles — almost every change has a webhook. Polling with GraphQL is rare.
Our pipeline is Linear webhook → Slack notification → Notion row, so the starting point is a tiny server that accepts Linear webhooks.
1.3 Where do you run this thing
Three common choices:
- Vercel Functions / Cloudflare Workers — serverless. 100 to 300 ms cold start, generous free tier, secrets ride as env vars. Downside: background work is hard. You must respond within 30 seconds.
- Bun/Node single process on systemd or Fly.io — always on, basically required if you want Socket Mode. Costs 5 to 10 dollars a month.
- Lambda + EventBridge — for high traffic with strict SLOs. Setup overhead is real.
The hands-on assumes Bun + Fly.io. It's the simplest model for a 200-line tool.
Chapter 2 · Notion API 2026 — the Data Sources API shock
2.1 The data model changed
In August 2025 Notion shipped "multi-source databases" and rolled out matching API changes. A single database can now hold multiple data sources, and the API surface had to follow.
- Old (
2024-09-25and earlier): a database = a single table. You queried pages (rows) withdatabases.query. - New (
2025-09-03onward): a database contains one or more data sources. Pages are children of a data source, not directly of the database.
New endpoints:
GET /v1/data_sources/:id— data source metadata.POST /v1/data_sources/:id/query— query pages. ReplacesPOST /v1/databases/:id/query.POST /v1/data_sources— create a new data source.
If you want to leave existing code untouched, pin Notion-Version: 2022-06-28 and it'll keep working for a while — but you can't use the new multi-source features that way.
2.2 Minimum snippet — append a row to a database
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') } }] },
},
})
Key points:
parentisdata_source_id, notdatabase_id. Old code useddatabase_idand still works in compatibility mode.propertieskeys must be the exact column names from the database, including case and spacing.- The title column name isn't always "Title". Each database has its own title column, so in production you usually fetch the schema once via
GET /v1/data_sources/:idand look up the title column name.
2.3 Finding your data source ID
The Notion UI shows the database ID but not the data source ID. One shell command does the trick.
curl -X GET "https://api.notion.com/v1/databases/$DATABASE_ID" \
-H "Authorization: Bearer $NOTION_TOKEN" \
-H "Notion-Version: 2025-09-03"
The first element of the response's data_sources array carries the id. For single-source databases there's exactly one.
2.4 Rate limits
Notion guarantees an average of three requests per second. Short bursts are allowed but sustained traffic gets rate_limited errors (status: 429). Mitigations:
- Read the
Retry-Afterheader and sleep for that long. - Cap concurrency at three with something like
p-limit. - If a single workflow creates 100 pages, push them through a queue instead of slamming the endpoint.
Chapter 3 · Slack API — Bolt 4 + Socket Mode hands-on
3.1 Which SDK
In Node and Bun the de facto standard is @slack/bolt. v4 shipped in 2025 and bundles the Web API, Events API, Socket Mode, and interactive components (buttons, modals) into one object.
In this workflow Slack does two things:
- Posts the issue-closed notification to a channel —
chat.postMessage. - Optionally lets a user press "skip changelog" to cancel the Notion write — Block Kit action + Events API.
3.2 The simplest start — an incoming webhook
If you only need to post, you don't even need an 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()}`)
}
}
The catch: a webhook is bound to one channel at install time. If you need to vary the channel at runtime, switch to a bot token plus chat.postMessage.
3.3 Bot token + chat.postMessage
The skeleton of a Block Kit message — header, section, context, action button.
import { WebClient } from '@slack/web-api'
const slack = new WebClient(process.env.SLACK_BOT_TOKEN!)
await slack.chat.postMessage({
channel,
text: `${issueTitle} closed`, // used for notification previews
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 is the notification preview, so don't leave it blank. Full version in Chapter 6.
3.4 Receiving interactions with Socket Mode
To handle the "Skip changelog" button you need to process inbound events. Internal tools usually sit behind a corporate firewall, and Socket Mode is dramatically easier.
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) // tag in KV, consult right before the Notion write
})
await app.start()
Three reasons Socket Mode is nice: no inbound port, no ngrok or cloudflared tunnel, local dev and production share the same code. One downside — horizontal scaling is awkward. Two instances mean every event arrives twice. At internal-tool scale, a non-issue.
3.5 Slack rate limits
chat.postMessageis per-channel Tier 4, 100 calls per minute, you basically don't have to worry.- Global tiers like
users.listcap at 50 a minute, so cache those if you call them often. - 429 responses include
Retry-After. Sleep that long and retry.
Chapter 4 · Linear GraphQL — SDK and webhooks
4.1 The SDK is the right answer
Linear maintains an official TypeScript SDK, @linear/sdk. 4.x is the stable line. It puts typed methods on top of the GraphQL schema, so you don't have to hand-write queries.
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, ...
// PRs ride in as attachments
const prUrls = attachments.nodes
.filter((a) => a.url.startsWith('https://github.com/') && a.url.includes('/pull/'))
.map((a) => a.url)
4.2 Webhooks — never skip signature verification
Linear sends an HMAC-SHA256 signature in the Linear-Signature header. Always verify it — otherwise anyone can fake a payload and trigger your automation.
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))
}
The Bun receiving pattern is identical to the serve({...}) in the Chapter 6 full script. The rule is return 200 fast — Linear retries if your response takes more than five seconds, and after enough failures it disables the webhook. Push heavy work onto queueMicrotask and reply immediately.
4.3 Catching the moment something went to Done
Webhook payloads describe the action and the current data but don't give you the previous state. Two options:
- Local state cache — record the last-seen state per issue in KV and diff. Most accurate.
- State ID branching — check whether the payload's
data.state.idequals the Done state's ID. The downside: you miss regressions like Done back to In Progress.
The hands-on uses option 2. The Done state ID is stable per workspace, so look it up once.
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 rate limits
- The GraphQL API uses a 1500 complexity points per hour model. A simple query is one point; a 100-item paginated query is ten.
- Check the
X-RateLimit-Requests-Remainingresponse header. - The SDK auto-retries once on a 429. You still own idempotency.
Chapter 5 · The LLM glue pattern — when to use one and when to skip it
5.1 What LLMs are actually better at
Boils down to three things.
- Summarization — collapsing 7 PRs and 30 commit messages into one changelog sentence.
- Classification — short branches like "is this issue bug/feature/chore/security".
- Drafting — writing the Slack title and body in a consistent voice.
Almost everything else is better served by a regex or a static map. Reach for an LLM and you sign up for cost, latency, and non-determinism.
5.2 A summarization call — Claude Sonnet 4.5 example
The skeleton of a summarization call:
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]
Cost sense: Sonnet 4.5 runs about 3 dollars per million input tokens, 15 dollars per million output. Each call is roughly 0.002 to 0.01 dollars. 100 a day stays under 30 dollars a month. The full prompt plus fallback shows up in Chapter 6.
5.3 Picking a model
- Short classification branches: a small model like Haiku, or GPT-4o-mini. Fast and cheap.
- Summaries and drafts: Sonnet or GPT-4o. The quality jump is noticeable.
- Reasoning: if you genuinely need it, use the reasoning mode. But workflow automation rarely calls for it.
5.4 Where not to put an LLM
- "If issue closed, send a notification." A clear branch.
- State conversions (
openbecomes "Open"). - Routing (this channel vs that channel) — a static lookup is enough.
If the mapping is deterministic, an LLM only adds occasional mistakes you'll spend hours tracking down.
5.5 Validate the output — JSON is not trustworthy
LLMs sometimes wrap JSON in chatter or drop required fields. Always safeParse with a Zod schema and fall back to something safe (category chore, summary equal to the issue title). The Chapter 6 script shows this pattern inline.
Chapter 6 · The full script — Linear closure → Slack → Notion
Combining the pieces. Single Bun process, about 200 lines.
// 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')
},
})
One file holds the whole workflow. Dependencies:
bun add @anthropic-ai/sdk @linear/sdk @notionhq/client @slack/web-api zod
Deploying to Fly.io:
fly launch --no-deploy
fly secrets set LINEAR_API_KEY=... LINEAR_WEBHOOK_SECRET=... # ...
fly deploy
Chapter 7 · Secrets management — 1Password, Doppler, Vercel
7.1 Never do this
- Commit
.envto git. Roughly 30% of accidental leaks start here. - Paste tokens into Slack channels. Retention of 90+ days makes them practically permanent.
- Use a production token in local development. One mistake away from a real incident.
7.2 1Password CLI — great cost-to-benefit for solo developers
Put references in .env.tpl like LINEAR_API_KEY=op://dev-secrets/linear-prod/password, then run op run --env-file=.env.tpl -- bun run server.ts. The plaintext secret never touches disk.
7.3 Doppler — when a team needs to share
Doppler is a hosted secrets manager. Per-workspace secrets, injected through doppler run -- bun run server.ts. The big win: the same interface works in CI/CD. Teams of five or fewer fit the free tier.
7.4 Vercel and Cloudflare environment variables
If you're deploying to a serverless platform, dropping secrets into the dashboard is the simplest move. The downside is per-environment manual sync. To automate CI injection, run vercel env pull once.
7.5 Secret rotation
Quarterly. The four tokens follow nearly the same dance — issue a new one, swap the env var, disable the old. The one exception is Notion, where the old token dies immediately on rotation, so keep two integrations side by side briefly if you need zero downtime. If you don't automate it, humans do it — and humans forget every time.
Chapter 8 · Operating it — rate limits, retries, observability
8.1 Retry policy
All three APIs serve transient 5xx and 429s. The policy in three lines:
- 5xx: exponential backoff, three attempts maximum.
- 429: honor
Retry-After. If missing, 1 second then 2 then 4. - 4xx other than 429: do not retry. That's a bug in your code.
A small helper is just a for loop with a try/catch that branches on status. Write it once and wrap every API call.
8.2 Idempotency
Webhooks are retried. The same issue-closed event arriving twice means two Notion rows. Two defenses:
- Record each event's
event.idin KV; ignore if seen. - Query for an existing row with the same Linear issue URL before creating a new one.
Default policy: assume duplicates always.
8.3 Observability and alerting
Structure your logs with one console.log(JSON.stringify({...})) line. Grep with fly logs. Invest an hour in OpenTelemetry and it gets much better — the Honeycomb free tier is generous.
The most common failure pattern: an automation breaks and nobody notices for a week. Catch at the top of the handler and post one :rotating_light: workflow failed: ${msg} line to a channel like #alerts-dev.
Chapter 9 · The honest comparison — trade-offs vs Zapier and n8n
9.1 When building it yourself wins
- The logic is not flat — five branches, conditional matching, an LLM call, an external KV. Doing that in an iPaaS GUI is a debugging nightmare.
- Task cost is exploding — Zapier charges per task, so a high-volume workflow gets expensive quickly. Custom code is a 5-dollar infra line.
- Policy bans secrets from a SaaS — compliance teams reject Zapier holding your OAuth tokens for a reason.
- You need fast deploy and rollback — code:
git revert. GUIs have weak change history.
9.2 When Zapier or n8n wins
- Many varied workflows — 30 automations as 30 scripts is operational pain. n8n in one place is better.
- Non-developers need to touch it — marketing wants to tweak a trigger condition? You need a GUI.
- Frequent new SaaS connections — Zapier's 9,000+ integrations are powerful. Rolling your own means learning each SDK and auth flow.
- You can't afford maintenance time — code you wrote is code you keep. A one-person team carries that on their back.
9.3 Cost simulation — 100 workflow runs a day
| Item | Zapier | n8n self-hosted | Custom code |
|---|---|---|---|
| Hosting | 0 (SaaS) | 5 USD/month (Fly.io) | 5 USD/month (Fly.io) |
| Task cost | 3,000 tasks at 0.02 USD = 60 USD/month | 0 | 0 |
| LLM API | not included | not included | 30 USD/month (Claude) |
| Dev time | 2 hours | 6 hours | 10 hours |
| Maintenance/month | 0.5 hours | 1 hour | 0.5 hours |
| Total monthly | 60 USD+ | 5 USD+ | 35 USD+ |
Drop the LLM and custom code costs 5 dollars a month. Spend the time once and the marginal cost is effectively zero.
9.4 Recommendation
- Five or fewer automations: build it yourself. You'll learn faster.
- Five to thirty: n8n self-hosted. Use the integration catalog.
- Thirty or more with non-developer touch: Zapier or Make.
- Heavy compliance, can't share SaaS tokens: custom code with an in-house key manager.
Epilogue — checklist and anti-patterns
Hands-on checklist
- Secrets are never in git
- Linear webhook signatures are verified
- An idempotency key is stored in KV
- LLM output goes through a Zod schema
- Failure pings an alerts channel
- Retries do not run on 4xx (those are your bugs)
- The Notion integration is explicitly shared with the target database
- The Slack bot is invited to the channel (otherwise
not_in_channel) - All six env vars are set in production
- Logs are structured
Anti-patterns
- An LLM on every branch — non-determinism stacks up. Simple maps don't need an LLM.
- No
Notion-Versionheader — without one you're routed to the latest version and break on migrations. Always pin it. - Heavy work inside the webhook — anything that can't finish within five seconds belongs on a queue. Otherwise the webhook gets disabled.
- Retry storms — retrying 4xx means pushing your bug onto an external API. Alert and stop.
- Hardcoded channel and database IDs — keep them in env vars. Moving services later should require no code edits.
- Two Socket Mode instances — they double-process events. A single instance is the right answer at internal-tool scale.
- Trusting JSON output — LLMs sometimes wrap it in commentary. Always parse and validate.
- Skipping rotation — quarterly. Put it on the calendar, otherwise it doesn't happen.
- PII straight into the LLM — don't drop customer emails or PII into the prompt. Mask or use an in-house model.
- Swallowing errors — empty
try/catchblocks are the enemy. At minimum, log them structured.
Next post
The next piece is a hands-on for wiring OpenTelemetry, Tempo, and Grafana into an internal automation. Spend five minutes adding distributed tracing to this 200-line script and you can answer "why did this take 5 seconds yesterday and 50 seconds today?" You'll see the Anthropic, Slack, Linear, and Notion call latencies on a single screen.
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