Announcing CIMD support for MCP Client registration
Learn more

How to Build an Auto Release Notes Agent with GitHub, Notion, and Slack

Saif Ali Shaik
Founding Developer Advocate

TL;DR

  • An agent that polls GitHub for merged pull requests, pulls the commits on each one, writes a structured page into a Notion database, and posts the link to Slack. No webhooks, no tunnel, no cloud deploy; it runs from a laptop or a cron entry.
  • Idempotency comes from a local state file of processed PR numbers, not from a Notion query. Every PR produces exactly one page and one Slack message even if polling windows overlap or the process restarts.
  • The hard part is not the pipeline. It is that GitHub, Notion, and Slack each expose more than one connector variant with different tool names, different parameter names, and different auth models. All three are handled by Scalekit AgentKit behind one actions.execute_tool() call.
  • Between them these three connectors expose 359 tools. This agent calls four. Guessing which four costs more time than writing the pipeline, and this post shows the two places that guess goes wrong.
  • Clone the repo, or start from the auto release notes agent template. Running in under 30 minutes.

Release Notes Are Written by Whoever Forgets to Leave

The release goes out Thursday. On Friday someone opens GitHub, filters closed pull requests, reads thirty titles, decides which twelve mattered, and writes them up in Notion. Then they paste the link in Slack, where roughly four people see it.

The information already exists. It is in the PR titles, the PR descriptions, the commit messages, and the merge SHAs. Nobody needs to invent anything. The work is transcription, and it is the kind of transcription that gets skipped the week it matters most, because the week it matters most is the week everyone is busy.

An agent does not get busy.

How the Agent Works: A Deterministic Pipeline

There is no reasoning loop here. The agent polls, filters, writes, and notifies, in that fixed order, then sleeps. It holds no memory between cycles beyond a list of PR numbers it has already handled.

Every cycle runs four steps:

  1. Poll. Ask GitHub for recently closed pull requests, then filter to the ones that were actually merged inside the lookback window.
  2. Enrich. Pull the commits on each new PR so the release note describes the changes rather than just the title.
  3. Publish. Insert a row into the Notion release notes database with the PR metadata as queryable properties and the commit list as page content.
  4. Notify. Post the Notion page URL to Slack, attributed to the engineer who merged it.

Polling rather than webhooks is a deliberate choice. A webhook needs a public URL, which means a tunnel in development and a deployed endpoint in production, plus HMAC signature verification and a replay strategy. For a workflow whose acceptable latency is measured in minutes, none of that earns its keep. The repo ships both; the polling server is the one to run.

Where These Three Connectors Fight You

Every one of these three services ships more than one connector in the catalog, and they are not interchangeable. Picking wrong is not a config error you notice at setup; it is a runtime failure that reads like a data problem.

GitHub Has Three Connectors and an App-Type Boundary

The catalog carries a plain GitHub connector with 217 tools on OAuth 2.0, a personal access token variant with the same 217 tools on a bearer token, and a GitHub MCP connector with 44 tools on OAuth 2.1. This agent uses the plain OAuth connector.

Setup is the least painful of the three services. Create an OAuth app in GitHub Developer Settings, paste the Scalekit redirect URI into the Authorization callback URL field, and copy the Client ID and Client Secret back into Scalekit. Note the ordering detail: in the Scalekit dashboard you have to click Use your own credentials before the redirect URI appears.

The constraint that catches people later is the OAuth app boundary. A GitHub OAuth app is not a GitHub App, and some endpoints are closed to it. Creating a check run, for instance, requires a GitHub App; OAuth apps and authenticated users cannot create a check suite at all. If your release notes agent later grows into a release gate that wants to post status checks, that is where you hit the wall, and no amount of scope tuning gets you past it. Full tool list is in the GitHub connector docs.

Recommended Reading: GitHub MCP vs API for AI agents

Notion Wants Capabilities, a Hyphenated UUID, and the Word "title"

The Notion connector is 51 tools on OAuth 2.0, and there is no managed-app shortcut. You register your own public integration at Notion Integrations, paste the Scalekit redirect URI into the OAuth Domain & URIs section, and take the OAuth client ID and secret from the integration's Secrets tab. In Scalekit's Permissions field you enter Notion capabilities, not OAuth scopes; the vocabulary is different from every other connector in this stack.

Three things then bite in sequence.

  • The database ID must be hyphenated. Notion URLs carry a bare 32-character hex string. Every database tool wants it as a UUID with hyphens in the 8-4-4-4-12 pattern. Pass the raw string and the call fails.
  • Merged and synced databases need different tools entirely. notion_database_query and notion_database_insert_row use the older endpoint that assumes one data source per database. If your release notes database is merged, synced, or multi-source, those calls return an Invalid request URL error and you have to switch to notion_data_source_fetch to get a data_source_id, then notion_data_source_insert_row. The error message does not tell you this. The docs do.
  • Property keys are case-sensitive, and the title property is not called what you think. This is the one that costs an afternoon, and it is worth quoting the rule exactly: for title fields, always use title as the property key, not Name or any other display name. For every other property, use the exact name from the database schema, matching case. The recommended workflow is to call notion_database_fetch first and read the schema rather than assuming it. Skip that step and you get Invalid property identifier, which reads like the connector cannot write properties at all. It can. You are just calling the title column by its display name.
  • And one more format trap. Two Notion tools take blocks, and they take them differently. The child_blocks argument on notion_database_insert_row accepts Notion API block objects. notion_page_content_append does not; it takes a simplified shape of type plus a plain text string and converts internally. Pass raw Notion blocks to the append tool and it will not do what you expect.

Slack Ships Two Connectors With Different Parameter Names

The plain Slack connector is 91 tools on OAuth 2.0, and slack_send_message takes channel and text. The Slack MCP connector is 19 tools on OAuth 2.1, and slackmcp_slack_send_message takes channel_id and message. This agent uses the plain connector. Passing the other pair fails at runtime with an error that looks nothing like a naming problem.

Slack MCP also requires enabling Model Context Protocol inside the Slack app itself before the connection will authorize, under Features then Agents & AI Apps. If you have not done that and you picked the MCP variant, the OAuth flow has nothing to grant.

Recommended Reading: Slack MCP vs API for AI agents

The Two Tool Names Everybody Gets Wrong

The failures above are configuration. This one is different, and it is the reason release notes agents ship half-finished.

Here is what a release note should contain: what changed. Here is what this agent's Notion page contains if you build it the obvious way: the PR title and the PR description. No commits. The reason is a single wrong tool name.

The tool that lists commits on a pull request is github_pull_request_commits_list. It takes owner, repo, and pull_number, and it has been in the connector all along. The name a reasonable developer reaches for is github_pull_commits_list, which does not exist. Call it, get an error, conclude the capability is missing, ship release notes without commit lists, and leave a code comment explaining the limitation. The limitation is not real.

The Notion property failure has the same shape. The connector does accept a full properties object mapping column names to Notion property values. What it rejects is Name as the key for a title column. One wrong key looks identical to "properties are not supported," and the workaround, dumping all the metadata into a callout block, produces a Notion database whose columns are empty. Which means it cannot be filtered, sorted, or grouped. Which means it is not a database; it is a list of pages.

Both failures come from the same place: writing tool calls from memory instead of from the surface. That surface is retrievable. list_scoped_tools returns the tools the current user's connected account is actually authorized to call, with their real names and parameter schemas, and notion_database_fetch returns the real property names for the target database. Two calls at build time replace two guesses that ship as permanent limitations.

The fix is not a better memory. It is not guessing.

Recommended Reading: Why tool calls fail in production

Prerequisites

  • A Scalekit account; the free tier is sufficient. Credentials come from Developers then API Credentials at app.scalekit.com.
  • A GitHub OAuth app and a repository with merged pull requests to read.
  • A Notion database for release notes, shared with your integration, with a title column and whatever metadata columns you want to filter on.
  • A Slack workspace and a channel the connected account can post to.
  • Python 3.11 or newer.

How Scalekit Collapses Three Auth Flows Into One Interface

Configure each connection once in the dashboard. After that, every call to GitHub, Notion, and Slack goes through the same method. Token storage, expiry, and refresh are handled server-side, and no credential appears anywhere in the agent.

  • Configure once, run forever. Each connector completes OAuth one time per user. On the first run, anything not ACTIVE surfaces an authorization link.
  • One call pattern for everything. No PyGithub, no notion-client, no Slack WebClient. Three services, one method signature.
  • Credentials never touch the agent runtime. The agent passes an identifier and a connection name. Scalekit resolves the token from the token vault, injects it, makes the call, and returns the result.

That last point is worth holding against the .env file. If your configuration contains a NOTION_API_KEY or a GITHUB_TOKEN alongside your Scalekit credentials, you have two credential systems and only one of them is vaulted. Delete the raw tokens. The only secret the agent should hold is the Scalekit client secret.

There is an accuracy argument too. Across these three connectors the catalog exposes 359 tools: 217 for GitHub, 91 for Slack, 51 for Notion. This agent calls four. Handing a model the full catalog degrades tool selection and burns context before any work happens; the agent should see only the tools the current user's connected account authorizes.

Recommended Reading: Token-Efficient Tool Calling: Auth Overhead in Agent Context

Ready to wire it up? Start free and follow the AgentKit quickstart.

How to Set Up Your Connectors in Scalekit

Step 1: Create Your Scalekit Workspace

Create a workspace at app.scalekit.com and copy SCALEKIT_ENV_URL, SCALEKIT_CLIENT_ID, and SCALEKIT_CLIENT_SECRET into your .env.

Step 2: Add the GitHub Connector

Go to AgentKit then Connections then Create Connection, find GitHub, click Use your own credentials, and copy the redirect URI. Paste it into your OAuth app's Authorization callback URL at GitHub Developer Settings, then bring the Client ID and Client Secret back into Scalekit.

Step 3: Add the Notion Connector

Create the connection, copy the redirect URI, and register a new integration at Notion Integrations with that URI in OAuth Domain & URIs. Take the OAuth client ID and secret from the Secrets tab. Then share your release notes database with the integration; an authorized workspace is not the same as a shared database, and an unshared database returns empty results rather than an error.

Step 4: Add the Slack Connector

Create the connection and complete OAuth with a scope that permits posting. Invite the connected account into the channel you set as SLACK_ANNOUNCE_CHANNEL before the first run.

Then copy the exact connection names from the dashboard into your .env. Scalekit auto-suffixes them per workspace, so the connection you think of as github may be github-2. Passing connection_name explicitly on every call is what prevents a multiple connected accounts found error when one identifier holds several connections of the same provider type.

Setting Up Auth With Claude Code

claude plugin marketplace add scalekit-inc/claude-code-authstack && claude plugin install agent-auth@scalekit-auth-stack

Then prompt Claude Code:

Set up Scalekit auth for GitHub, Notion, and Slack. I need to list pull requests and their commits in GitHub, insert rows into a Notion database, and post messages to Slack. Before writing any tool call, use list_scoped_tools to get the real tool names and parameter schemas for the connected account, and call notion_database_fetch to read the target database's property names. Do not write tool names from memory. Use actions.execute_tool() for all connectors and pass connection_name explicitly alongside identifier.

The middle paragraph is the important one. It is the instruction that would have caught both of the failures in the previous section.

Claude Code generates the client and a shared execution wrapper. Retry logic belongs here rather than at each call site, because the three services fail differently and only some failures are worth retrying:

def execute_action_with_retry(self, identifier, connector, tool, parameters, max_attempts=3): """Execute a Scalekit tool with exponential backoff on transient failures.""" backoff = 1 for attempt in range(1, max_attempts + 1): try: response = self.client.actions.execute_tool( tool_name=tool, connection_name=connector, identifier=identifier, tool_input=parameters, ) return response.data or {} except ScalekitException as e: error_msg = str(e) is_rate_limit = "429" in error_msg or "rate limit" in error_msg.lower() is_transient = any(err in error_msg.lower() for err in ["timeout", "connection", "temporary", "unavailable"]) if (is_rate_limit or is_transient) and attempt < max_attempts: logger.warning(f"{tool} failed (attempt {attempt}): {error_msg}") time.sleep(backoff) backoff *= 2 else: logger.error(f"{tool} failed permanently: {error_msg}") return None return None

A 429 or a timeout is worth retrying. A wrong tool name, a bad property key, or a revoked token is not, and retrying them three times just delays the error you need to see. The classification above is the whole difference between a resilient agent and one that hides its own bugs.

Locally, identity comes from SCALEKIT_DEFAULT_IDENTIFIER. In production, resolve each engineer's real user ID server-side from your authenticated session and pass it as identifier, so the Notion page and the Slack message are attributed to the person who actually merged the PR rather than to a shared bot.

Recommended Reading: Why Admin Accounts Are the Wrong Default for AI Agents

Step 1: Polling GitHub for Merged Pull Requests

GitHub has no "list merged PRs" endpoint. It has closed PRs, and merged is a property of some of them. So the agent asks for closed PRs sorted by recency and filters in process:

def get_recent_merged_prs(self): data = self.connector.execute_action_with_retry( identifier=Settings.SCALEKIT_DEFAULT_IDENTIFIER, connector=Settings.GITHUB_CONNECTOR, tool="github_pull_requests_list", parameters={ "owner": Settings.GITHUB_REPO_OWNER, "repo": Settings.GITHUB_REPO_NAME, "state": "closed", "per_page": 30, "sort": "updated", "direction": "desc", }, ) or {} all_prs = data if isinstance(data, list) else data.get("array", []) cutoff = datetime.now(timezone.utc) - timedelta(hours=24) merged = [] for pr in all_prs: merged_at = pr.get("merged_at") if not merged_at: continue # closed but not merged when = datetime.fromisoformat(merged_at.replace("Z", "+00:00")) if when >= cutoff: merged.append(pr) return merged

Two details worth knowing. The response arrives wrapped as {"array": [...]} rather than a bare list, so the unwrap above is not defensive padding. And merged_at being null is how GitHub distinguishes a closed-without-merging PR from a merged one; there is no separate state value for it.

The 24-hour window plus a 30-PR page is a bounded read, not a full history scan. A repository merging more than 30 PRs a day needs pagination added here.

Step 2: Fetching the Commits

This is the call the naive version skips. Release notes that list only PR titles are a table of contents; the commits are the content.

def get_pr_commits(self, pr_number): data = self.connector.execute_action_with_retry( identifier=Settings.SCALEKIT_DEFAULT_IDENTIFIER, connector=Settings.GITHUB_CONNECTOR, tool="github_pull_request_commits_list", parameters={ "owner": Settings.GITHUB_REPO_OWNER, "repo": Settings.GITHUB_REPO_NAME, "pull_number": pr_number, "per_page": 100, }, ) or {} return data if isinstance(data, list) else data.get("array", []) def summarize_commits(commits, limit=8): """First line of each commit message, with author and short SHA.""" lines = [] for c in commits[:limit]: message = (c.get("commit", {}).get("message") or "").split("\n")[0] author = (c.get("author") or {}).get("login") or \ c.get("commit", {}).get("author", {}).get("name") sha = c.get("sha", "")[:7] lines.append(f"{message} ({sha})" + (f" by {author}" if author else "")) if len(commits) > limit: lines.append(f"and {len(commits) - limit} more") return lines

The connector's own note on this tool is that results may not include every commit on very large pull requests, which is a GitHub limit rather than a connector one. Capping the display at eight and counting the remainder keeps the page readable either way.

Step 3: Writing the Notion Page With Real Properties

Read the schema before writing to it. One call, once, and it removes the entire class of property errors:

schema = connector.execute_action_with_retry( identifier=identifier, connector=Settings.NOTION_CONNECTOR, tool="notion_database_fetch", parameters={"database_id": Settings.NOTION_DATABASE_ID}, ) print(list((schema or {}).get("properties", {}).keys())) # ['Name', 'PR SHA', 'PR Number', 'Repository', 'Status', 'Summary']

Now build the properties payload. The title column is called Name in the Notion UI and title in the API, and every other column is passed under its exact display name:

properties = { "title": {"title": [{"text": {"content": pr["title"]}}]}, "PR SHA": {"rich_text": [{"text": {"content": pr["merge_commit_sha"]}}]}, "PR Number": {"number": pr["number"]}, "Repository": {"rich_text": [{"text": {"content": repo_full_name}}]}, "Status": {"select": {"name": "Merged"}}, "Summary": {"rich_text": [{"text": {"content": (pr.get("body") or "")[:2000]}}]}, }

Note title in lowercase for the first entry and PR SHA with its space and capitals preserved for the second. That single distinction is the difference between a queryable release database and a pile of pages.

The commit list goes into the page body as child blocks, which take Notion API block objects on this tool:

child_blocks = [ {"object": "block", "heading_2": { "rich_text": [{"text": {"content": "Commits"}}]}}, ] for line in summarize_commits(commits): child_blocks.append({"object": "block", "bulleted_list_item": { "rich_text": [{"text": {"content": line}}]}}) result = connector.execute_action_with_retry( identifier=identifier, connector=Settings.NOTION_CONNECTOR, tool="notion_database_insert_row", parameters={ "database_id": Settings.NOTION_DATABASE_ID, "properties": properties, "child_blocks": child_blocks, }, ) notion_url = (result or {}).get("url")

With properties populated, the release notes database becomes filterable by repository, sortable by PR number, and groupable by status. That is the entire reason for using a Notion database instead of appending to a page.

Step 4: Posting to Slack as the Right Person

The Slack message is one call, and the interesting parameter is the identifier rather than the payload:

connector.execute_action_with_retry( identifier=author_identifier, connector=Settings.SLACK_CONNECTOR, tool="slack_send_message", parameters={ "channel": Settings.SLACK_ANNOUNCE_CHANNEL, "text": (f"Release notes for PR #{pr['number']} in {repo_full_name}\n" f"{pr['title']}\n{notion_url}"), }, )

author_identifier resolves from a user_mapping.json that maps GitHub usernames to the Scalekit identifiers of the people behind them, falling back to a default when a merger is not mapped. The mapping file is the local stand-in for what production should do: resolve the identity from your own user directory.

The reason this matters is not cosmetic. With a shared bot token, every release note is posted by the bot, and the Slack audit trail says the bot did it. With per-user connected accounts, the message is posted under the merging engineer's own authorization, and what that engineer cannot do in Slack, the agent cannot do either. Scope follows identity rather than connector configuration.

Idempotency Is the State File, Not the Database

It is tempting to make this idempotent by querying Notion for an existing page with the same merge SHA before inserting. That read costs an API call per PR per cycle, and it still races against a second process.

The agent does something simpler. It keeps a JSON file of PR numbers it has already processed, checks membership before doing any work, and writes the file after a successful cycle:

def poll_once(self): merged = self.get_recent_merged_prs() new_prs = [pr for pr in merged if pr["number"] not in self.seen_prs] if not new_prs: logger.info(f"All {len(merged)} merged PRs already processed") return for pr in new_prs: if self.process_pr(pr): self.seen_prs.add(pr["number"]) self.save_state() time.sleep(2) # spacing between PRs, not a rate-limit fix

Be honest about what this guarantees. The state file makes the agent safe to restart and safe to run on overlapping schedules on one machine. It does not coordinate across machines, and it does not survive being deleted. If the process dies between creating the Notion page and writing the state file, the next cycle creates a duplicate page. That is a narrow window and a cheap failure, and the alternative is distributed locking for a workflow that publishes release notes.

If you need stronger guarantees, mark the PR as seen before processing rather than after. You then trade duplicate pages for occasionally missed ones, which is the right trade for some teams and the wrong one for others. Pick deliberately.

How to Run It

pip install -r requirements.txt python polling_server.py --interval 60

A typical cycle:

Starting polling cycle at 2026-08-31 09:00:12 Checking for merged PRs in your-org/your-repo Found 14 closed PRs total Found 2 merged PRs in last 24 hours Processing PR #482: Add rate-limit middleware Fetched 6 commits Notion page created: https://notion.so/Add-rate-limit-middleware-2f81... Posted to #releases Processing PR #481: Fix token refresh race on cold start Fetched 3 commits Notion page created: https://notion.so/Fix-token-refresh-race-2f80... Posted to #releases Polling cycle complete - processed 2 PRs Sleeping for 60 seconds...

For a single pass rather than a loop, python polling_server.py --once runs one cycle and exits, which is what you want from cron:

*/5 * * * * cd /path/to/agent && python polling_server.py --once >> logs/run.log 2>&1

To reprocess, delete the state file. The 24-hour lookback bounds how far back that goes.

What to Check Before You Go Live

Tool names. Call list_scoped_tools against your own connected account and confirm every tool name in the code appears in the result. This is a two-minute check that catches the failure mode described earlier.

Notion schema. Call notion_database_fetch and confirm your property keys match, exactly and case-sensitively. If the database is merged or synced, switch to notion_data_source_fetch and notion_data_source_insert_row.

Credentials. Your .env should contain Scalekit credentials, repository and database identifiers, and nothing else. A NOTION_API_KEY or a GITHUB_TOKEN sitting in there is a second, unvaulted credential path.

Volume. More than 30 merged PRs in a 24-hour window overflows a single page of results. Add pagination before that becomes true, not after.

Channel membership. The Slack connected account has to be in the announce channel. This fails silently enough to waste an afternoon.

Adding Another Destination

The pattern extends without touching auth. Mirroring the notes to Linear, appending them to a Confluence space, or opening a GitHub Release from the same data all follow the same three steps: add the connection in the dashboard, look up the real tool name and schema, call execute_tool.

Scalekit maintains each connector. You maintain none of them.

The full source is on GitHub. Browse the rest of the catalog in the connector docs, check the Python SDK reference, or start free and work through the AgentKit quickstart.

For adjacent engineering agents on the same auth layer, see the DevOps assistant agent for GitHub, Linear, and Slack, the daily standup agent, or building a multi-user GitHub agent with LangChain and OAuth.

FAQ

Why poll instead of using a GitHub webhook?

A webhook needs a publicly reachable URL, which means a tunnel locally and a deployed endpoint in production, plus signature verification and replay handling. For a workflow where a few minutes of latency is fine, polling removes all of that. The repo includes a webhook server if you want instant delivery and are willing to host it.

How do I know which tool name to use?

Call list_scoped_tools for the connected account. It returns the tools that account is authorized to call, with real names and parameter schemas. Every tool name in this post came from the connector docs or that call, and the two most common bugs in agents like this one are tool names written from memory.

Why does my Notion write fail with "Invalid property identifier"?

Almost always the title column. Use title as the key, in lowercase, regardless of what the column is called in the Notion UI. For every other column, match the schema name exactly including case and spaces. Run notion_database_fetch to see the real names.

Can I run this for a whole engineering team?

Yes, and you should. Pass each engineer's real user ID as identifier rather than a shared bot account, so each Notion page and Slack message is attributed to the person who merged the PR. Scalekit stores and refreshes each engineer's tokens independently. See single-tenant versus multi-tenant tool calling for the wider pattern.

What happens if the same PR is processed twice?

You get a duplicate Notion page and a duplicate Slack message. The state file prevents this in normal operation, including across restarts, but it does not coordinate across machines and it does not survive a crash between the Notion write and the state save. Run one instance per repository.

Does the agent ever modify the repository?

No. It calls two read tools on GitHub and writes only to Notion and Slack. If you extend it to open releases or post status checks, note that creating check runs requires a GitHub App rather than an OAuth app.

No items found.
Agent
Auth Quickstart
On this page
Share this article
Agent
Auth Quickstart

Acquire enterprise customers with
zero upfront cost.

Every feature unlocked. No hidden fees.