
Review season arrives and the manager's job is not judgment. It is retrieval. Ratings live in an Airtable table, written notes live in a Google Form, and the two only line up if someone types the same employee name in both places. A manager with six reports spends a weekend copy-pasting before writing a single sentence of actual assessment.
So you automate it. An agent reads Airtable, reads the Form, groups by employee, writes a summary page in Notion, and DMs the manager a digest. Two hundred lines, one afternoon.
Then someone asks a question you do not have a good answer to: whose credential is reading that table?
Because the table has every employee in it. Not just this manager's six. If the agent authenticates as a shared HR bot, the manager's digest is correct only because your application code filtered it correctly. One wrong WHERE clause and a manager reads their peer's feedback about their own team. Nothing errors. The page renders. It is simply wrong in a way that is very difficult to walk back.
This agent has one property none of the other templates have: it reads data about people who are not the person running it. That changes what "correct" means.
Here is the scoping logic, verbatim from aggregator.py:
Read the order of operations. list_all_records() has already run. Every review record in the base is in memory, for every employee, under every manager. The filter happens after.
That is a fine design for a single-manager script you run yourself. It is the wrong design the moment this becomes a People Ops tool that several managers trigger, because the isolation is a == comparison in Python rather than a property of the credential. A misconfigured database query leaks data passively. A misconfigured agent acts actively: it writes the wrong summary to a Notion page and DMs it to the wrong manager, and now the leak has a timestamp and a reader.
Three ways to move the boundary, in increasing order of cost:
Filter at the connector, not in memory. airtable_list_records accepts a filter formula and a view parameter. Point AIRTABLE_VIEW at a per-manager view, or pass a formula constraining Manager Email, and the rows never reach the process. This is cheap and it is the first thing to do. It is still not isolation, because the credential could read the other rows if asked.
Scope the Airtable grant per manager. Airtable OAuth is granted per base. If each manager authorizes their own Airtable connected account against a base containing only their reports, then what the manager cannot read, the agent cannot read. This is real isolation, and it costs you a base-per-team data model.
Put approval in front of the write. The Notion write and the Slack DM are the two irreversible steps. Gating them behind a confirmation is the pattern described in access control for multi-tenant AI agents, and for review data it is worth the friction on the first few cycles.
The template ships the first version because it is the one you can run today. Be clear-eyed about which one you are deploying. There is a fuller treatment in how to implement least privilege for AI agent tool calls.
No reasoning loop, no memory, no decisions about what to fetch next. The agent runs a fixed sequence for one manager and one review period, then exits.
Two design decisions in there are worth naming because they are the ones that make re-runs safe.
The Notion write is an upsert, not a create. Running the same cycle twice updates the page in place instead of producing a second "Alex Kim, Q2 2026". The Slack DM is guarded by a processed-cycle state file keyed on (manager_email, review_period), so a re-run does not re-notify. The manager gets pinged once per period, no matter how many times the cron fires.
Four connectors is four catalogs, and this pipeline touches a thin slice of each.
83 tools available, nine invoked. For an agent that reads performance feedback, the size of that gap is not a token-efficiency footnote, it is a containment property. Two of the three Airtable tools this agent calls are schema-level (airtable_get_base_schema needs schema.bases:read, airtable_create_table needs schema.bases:write), which means the connected account is already carrying write access to the base structure. Handing that same account a full 26-tool surface puts record deletion one bad tool selection away from an employee's review history.
The agent receives only the tools the current connected account is authorized to call. Not the catalog. If you want that enforced at the endpoint rather than by convention in your code, a Virtual MCP server declares the allowed tool list per agent role and is worth reading before this goes anywhere near production HR data.
This is the detail that costs people an afternoon, so it goes before the setup steps rather than in troubleshooting.
Scalekit ships two variants of both Notion and Slack, and they are not interchangeable.
You need the Notion MCP variant. The page-creation tools live there. The plain Notion connector has almost twice the tool count and still does not expose notion-create-pages. Picking the bigger catalog is the wrong instinct here.
Slack works on either, with different parameter names. The repo detects which one you configured by substring match rather than an exact name lookup, because connection names are workspace-specific:
MCP connectors also wrap their payload. Every result arrives as a content envelope with a JSON string inside it, so the base connector unwraps before anything downstream sees it:
Skip the unwrap and data.get("results") returns None against a payload that clearly contains results, which reads exactly like an empty search.
Recommended reading: The posts on tool calling authentication for AI agents and credential ownership across agent tool-calling patterns go into when each surface is the right one.
Setting OPENROUTER_API_KEY sends each employee's name and their raw feedback comments to a third-party API to generate the narrative summary. That is not a footnote on a performance review agent. It is the decision.
Leave it unset and summarization stays local. The rule-based path produces a deterministic summary from the same data: overall average, per-category averages, and the first five comments verbatim.
The tradeoff is real in both directions. The rule-based summary never leaves your connected services and never invents anything, but it is a table with comments underneath, not a narrative. The LLM summary reads like something a manager would write, and it costs you an egress path for employee feedback that your data-handling policy may not permit. Assess it before the first real cycle, not after.
The prompt itself is constrained accordingly: temperature is 0.3, and the last instruction is to not invent facts not present in the feedback.
Configure all four before running anything. Step 0 checks their status immediately, and having all four active means the first run exercises the whole path.
Create a free account, create a workspace for this project, and copy SCALEKIT_ENV_URL, SCALEKIT_CLIENT_ID, and SCALEKIT_CLIENT_SECRET into .env.
Go to AgentKit > Connections > Create Connection and add Airtable. Complete the OAuth flow and grant access to the base holding your review table. The provisioning step needs schema.bases:read to inspect the base and schema.bases:write to create the table; if you would rather create the table by hand, the read scope alone is enough.
Add Google Forms with read access to your form and its responses.
Add a Notion MCP connection, not the plain Notion connector. Then share your parent page with the integration: open the page in Notion, click the three-dot menu, choose Connections, and add your Scalekit integration. Skipping the share step gives you an authorized connection that cannot see the page you configured.
Add Slack with chat:write or the MCP equivalent. No channel invite is needed because the agent sends a direct message.
The single most common cause of a failed first run: Scalekit auto-suffixes connection names per workspace, so airtable comes back as airtable-3j16TKTG and notionmcp as notionmcp-chAb8Lfz. The Step 0 auth check calls get_or_create_connected_account() with whatever name you configured, and a generic provider label will not match. Copy the exact names from the dashboard.
With the Scalekit plugin installed, the auth scaffold is two commands and a prompt.
Then prompt Claude Code:
It generates the client, the connector-to-identity map, and the shared base class every connector inherits from.
Four separate identity slots rather than one shared value is deliberate. In the common case they are all the manager's email. When they are not, that difference is exactly the thing you want visible in config instead of buried in a comment: an Airtable connection authorized by People Ops and a Slack connection authorized by the manager are two different principals, and the digest will read as coming from whoever holds the Slack grant.
Every call in the pipeline goes through one method. identifier is what tells Scalekit which connected account to resolve.
Most agent tutorials assume the destination already exists. This one checks, creates what it can, and refuses to start against broken configuration.
The default schema is Employee Name, Manager Email, Communication Rating, Impact Rating, Comments. Note that airtable_create_table makes the first field in the array the primary field, which is why the employee field is inserted at index 0.
Google Forms gets validated but not provisioned, and the reason is a real connector limit rather than an oversight. The Google Forms connector exposes 10 tools, and googleforms_create_form takes only a title and an optional document title. There is no add-question tool. So the agent checks the form is reachable, warns if FORM_EMPLOYEE_QUESTION_ID matches nothing, and tells you to go add the questions by hand.
Failing here exits with code 1 and an instruction, not a stack trace. An HR agent that silently proceeds against a form nobody has filled in produces empty summaries that look like poor performance.
Two sources, two pagination dialects. Airtable returns an offset token; Google Forms returns a nextPageToken. Both loops run to exhaustion, because a partial fetch in this pipeline means an employee's summary is missing feedback that exists.
Each source is fetched inside its own try, and a failure degrades to an empty list rather than aborting the cycle. If Airtable is down, the Form comments still make it into the summary, clearly labeled as having no ratings. Partial output beats no output here, because the alternative is a review cycle that stalls on an outage.
Reviewers type names inconsistently. Matching is case-folded and whitespace-stripped, and bundles are keyed by the canonical spelling from direct_reports so the Notion page and the Slack digest agree.
Normalization handles "alex kim " against "Alex Kim". It does not handle a reviewer typing "Alex". That response is logged and dropped, which is the right call for review data (silently attaching feedback to the wrong person is worse than losing it) but it means the warning log is load-bearing. Read it after every cycle.
Rating columns are discovered by pattern rather than hardcoded, so the agent works across differently named review templates:
Any Airtable field whose name matches, and whose value is numeric, is averaged. Non-numeric values in rating-looking columns are skipped rather than crashing the averaging.
Find by title, update if present, create if not.
Two things about notionmcp_notion-search that matter more here than they would elsewhere. It is a semantic search across the entire workspace, not a scan of children under a parent page. And parent_page_id is accepted by find_existing_child_page() but never actually used to constrain the query; the match is on exact title alone. For a workspace with one review parent page that is fine. For a workspace where two teams each have an "Alex Kim, Q2 2026" page, the upsert can resolve to the wrong one. Add the parent page ID to the title, or scope the search with the tool's data_source_url parameter, before running this across multiple teams.
The page body is rendered as Notion-flavored markdown: a ratings table, the narrative, then the raw comments verbatim underneath. Keeping the raw feedback on the page is deliberate. A manager reading an LLM-written paragraph should be able to check it against the source without leaving the page.
mark_processed() runs last, after the DM, and writes atomically via a temp file and rename. If the process dies before the DM lands, the cycle is not marked, and the next run retries. If it dies after, the manager is not re-pinged. Ordering that pair correctly is the difference between a cron job and a cron job that spams people.
Set MANAGER_SLACK_ID to a DM conversation ID (D...) or a channel ID (C...). It defaults to MANAGER_EMAIL, which only works if your Slack connector resolves DMs by email.
One cycle, then exit. Schedule it weekly:
Or run it continuously during an active review window:
Ctrl+C finishes the in-flight cycle and exits 130 without leaving partial Notion writes or a half-sent digest.
The exit codes are the monitoring surface, and they distinguish three states that all look like "nothing happened":
2 is the one to watch mid-cycle. It means reviewers have not submitted yet, which is a nudge-the-team signal rather than an engineering problem. Persistent 2 in the last week of a review window is worth an alert.
Decide the summarization path first. OPENROUTER_API_KEY set means employee feedback leaves your connected services. Unset means local, deterministic, and blunter. Make that call with whoever owns your data-handling policy, before the first real cycle.
Filter Airtable at the connector. Set AIRTABLE_VIEW to a per-manager view so rows outside the manager's scope never reach the process. It is one environment variable and it shrinks the blast radius immediately.
Set FORM_EMPLOYEE_QUESTION_ID explicitly. Leave it blank and the agent guesses the employee from the shortest text answer in each response, which is a heuristic that will eventually attribute someone's feedback to the wrong person. Get the real ID from googleforms_get_form.
Confirm the Notion parent is a page, not a database. A database ID produces a page that is created but empty, which reads like a content bug rather than a configuration one.
Fetch Forms responses incrementally once volume grows. googleforms_list_responses accepts a filter parameter on submission time, in the form timestamp > 2026-01-01T00:00:00Z. The template paginates everything every cycle; for a large form, filter to the current review window instead.
Log who read what. Four connected accounts touching employee review data is exactly the situation where "which credential read this row, on whose behalf, when" needs to be a query rather than an investigation. Audit trails for agent auth covers the event categories, and agent tool observability covers what to capture per tool call. Do this before HR asks, not after.
Delete the state file to reprocess. rm -f state/processed_cycles.json resets the manager-and-period guard. Useful for testing, dangerous on a shared box.
The retrieval problem is genuinely solved by four connectors and about two hundred lines. That part is not the interesting bit.
The interesting bit is that this agent reads feedback about people, and where you draw the credential boundary determines whether a mistake in your filtering logic is a bug or an incident. The template draws it in application code because that is what runs today on one manager's laptop. Moving it to the credential layer, one Airtable grant per manager, one connected account per identity, costs you a data model change and buys you the property that matters: what the manager cannot read, the agent cannot read.
The same pipeline extends without touching the auth layer. Swap Airtable for a BambooHR or Rippling connector and the ratings come from the HRIS. Add Google Docs and the summary lands in the review template your People Ops team already uses. Point Step 4 at a channel instead of a DM and it becomes a calibration prep digest. Once connected accounts are in place, adding a source is a new connector, not a new auth system.
The full code is on GitHub. Clone the repo, configure four connections, and run your first cycle in under 30 minutes. Start from the AgentKit quickstart, or browse the full connector catalog.
If you get stuck building your agent, join the Scalekit Slack community.
The template scopes one cycle to one MANAGER_EMAIL. To fan out, loop over your manager list and pass each manager's real user ID as the identifier on every Scalekit call, rather than a single shared value from .env. That is also the change that makes the scoping real: each manager's connected account carries their own grant, so the isolation stops depending on your filter logic being right.
Page-creation tools only exist on the MCP variant. The plain Notion connector exposes 51 tools to Notion MCP's 28 and still does not include notion-create-pages. If NOTION_CONNECTOR points at the plain connector, auth succeeds and Step 3 fails.
Because the connectors expose different surfaces. Airtable's API supports creating tables inside an existing base, so airtable_create_table handles it. The Google Forms connector's googleforms_create_form takes only a title; there is no add-question tool, so form structure stays a one-time manual step at forms.google.com. Airtable's base itself is also manual, for the same reason: no create-base endpoint exists.
They are skipped from both the Notion write and the Slack digest individually. Everyone else in the same cycle still gets processed. If nobody has feedback, the run exits 2 rather than writing empty pages.
Yes, in both directions. Notion pages are upserted by title, so a re-run updates in place instead of creating duplicates. The Slack DM is guarded by the processed-cycle state file, so the manager is not re-notified for a period already handled.
Each manager's own credential, wherever the data model allows it. A shared account can read every employee's feedback, which makes every manager's digest correct only by convention and makes the audit trail useless (every read attributes to the bot). The full argument is in credential ownership in agent tool calling and why admin accounts are the wrong default for AI agents.
No. Tokens live in Scalekit's token vault and are refreshed before each tool call, which matters for a weekly cron that may not run for seven days between invocations. See how to handle token refresh for AI agents for why reactive refresh on a 401 is the wrong pattern for scheduled agents.