
Most agents that claim a human in the loop do not have one. They post a message to Slack and keep going. The notification arrives, the action has already happened, and the "approval" is a record of something the human could not have prevented.
That is survivable when the action is a CRM update or a standup digest. It is not survivable when the action is emailing a candidate an offer at a specific salary. There is no undo. The number is out, the expectation is set, and the recruiter is now negotiating from a position they never agreed to.
So this agent blocks. It creates the offer in PandaDoc, leaves it in Draft, posts an approval request in Slack, and then stops running until the hiring manager reacts. Approve and it sends. Reject and it exits. No response in thirty minutes and it exits. In two of those three outcomes the candidate never hears anything at all.
The interesting engineering is not the pipeline. It is what it takes to turn a Slack emoji into something you can defend as an authorization decision.
Three properties. Miss any one and you have a notification that looks like a gate, which is worse than no gate at all because everyone downstream believes an approval happened.
Read-back. The agent has to be able to observe the manager's response. Sending a message and hoping is not a control. This is the whole reason the flow polls.
Identity. An 'ok' sign on the message is not an approval. An 'ok' from the hiring manager is. If the approval request goes to a shared #offers-approval channel, every member of that channel can resolve the gate, and the repo's own comment names the failure case bluntly: in principle that includes the candidate, if they happen to be in a shared channel. The gate must filter reactions by reactor.
Fail-closed. Every other agent template in this series degrades gracefully. If Notion is down, post to Slack anyway. If the LLM fails, use a template. That instinct is correct when the output is a summary and wrong when the output is an irrevocable email. Here, the absence of an approval must produce the same outcome as a rejection.
Here is the whole gate. Note what it does when nothing happens.
Reject is checked before approve. A message carrying both a 'ok' and a 'no' resolves as a rejection. That ordering is a policy decision, and it is the right one: two managers disagreeing is not consent.
There is one more thing worth pointing at, because it is a single character away from breaking the gate silently. approver_user_id is checked with is not None, so an empty string activates the filter rather than disabling it. Every reaction would be compared against "", nothing would ever match, and every offer would sit until the thirty-minute timeout with no error. The settings module normalizes for exactly this:
A fail-closed gate turns a config typo into a total outage rather than a leak. That is the correct trade for offer letters, and it is also why the timeout notice posted back to Slack matters: without it, a stuck gate looks identical to a manager who has not looked at Slack yet.
Recommended reading: Human-in-the-loop tool calling covers the general pattern this agent is one instance of, including where confirmation belongs relative to the tool call.
The repo routes the approval request through the SlackMCP connector rather than the plain Slack connector, and uses the plain connector only for the timeout and rejection notices. Two Slack connections, two purposes.
That split is worth examining rather than copying, because the current connector catalog says both connectors can read reactions:
The plain connector also exposes slack_list_reactions, slack_fetch_conversation_history, slack_get_conversation_replies, and slack_search_messages, so read-back is not exclusive to the MCP variant.
The consequential difference is the response shape, and it runs against the intuition that the MCP variant is the more capable one. slackmcp_slack_get_reactions returns prose. The repo parses it with a regular expression:
That regex matches a multiplication sign in a sentence. It works, and the repo verified it against the live server. It is also the most fragile line in the agent: if the MCP server rewords its response, the regex returns an empty list, the gate sees no reactions, and every offer times out. Fail-closed means the failure is safe. It does not mean the failure is visible.
If you are building this fresh, evaluate the plain connector's slack_get_reactions for the gate. Structured output beats parsing prose, and Slack's reactions payload carries reactor user IDs, which is the field the identity check depends on. Confirm the granted scopes on your own connection support the read before committing, since a connector exposing a tool and your OAuth grant permitting it are two different things. There is a fuller comparison in Slack MCP vs API.
MCP connectors also wrap every response in a content envelope, which has to be unwrapped before anything downstream sees structured data:
Four connections, four catalogs, and a very thin slice of each.
188 tools available, eight wrapped, seven on the path a successful offer takes (documents_details_get is exposed but not called by run_flow.py).
For an agent holding a recruiter's Gmail grant, the size of that gap is a containment property, not a token-efficiency note. gmail_send_message is one of 56 Gmail tools, and the others include reading and modifying the recruiter's mailbox. The agent receives only the tools the current connected account is authorized to call, and this pipeline names its seven explicitly. Whether that constraint lives in your code or is enforced at the endpoint is a decision worth making deliberately; least privilege for agent tool calls covers the difference.
Every call in this flow runs on the recruiter's own connected account. Not a shared HR bot.
That is not a stylistic preference. Three things depend on it:
Attribution. The candidate receives the offer email from the recruiter, not from hr-automation@. The PandaDoc document is owned by the recruiter's account. When someone asks in six months who extended this offer, the answer is a person.
Revocation. A recruiter leaves and their connected accounts are revoked. With a shared bot, offboarding a recruiter changes nothing about what the agent can still do in their name.
Scope. A shared HR account typically ends up with organization-wide PandaDoc and Gmail access because that is the path of least resistance during setup. A recruiter's own grant carries exactly what that recruiter could do by hand. What the user cannot do, the agent cannot do. The credential ownership in agent tool calling post goes deeper here.
The template allows a different identifier per connector, which looks redundant until you hit it:
The same person can be authorized under different identifier strings on different services, because the email they used to authorize PandaDoc is not necessarily the one they used for Slack. Check the Connected Accounts list in the dashboard for the exact identifier showing as connected rather than assuming an email will resolve.
Related, and the cause of one of the more confusing errors you can hit: connection_name is passed on every single execute_tool call in this repo. Without it, Scalekit disambiguates by identifier alone, which fails with multiple connected accounts found when the same identifier is registered under more than one connection for that provider. With two Slack connections configured, that is not an edge case here, it is the default state.
No LLM key. This agent has no model in it at all, which is worth saying plainly: the offer text comes from a template your legal team already approved, and nothing about compensation is generated.
PANDADOC_TEMPLATE_UUID has no fallback, and the setup has four failure modes that all produce confusing errors.
Templates and Documents are different resource types. PandaDoc's API rejects a document ID here with Template is not available. Go to the Templates tab specifically and copy the UUID from that URL, not from a document you created from a template.
Token names must match exactly. The agent sends candidate_name, role_title, base_salary, start_date, and company_name. A mismatched or missing token is silently left blank by PandaDoc rather than erroring, which produces an offer letter with an empty salary field. Check the rendered draft on your first run before you trust the pipeline.
The recipient role must exist on the template. PandaDoc's own default role name is Client, not Signer. A mismatch gives you Role 'X' does not exist. Set PANDADOC_RECIPIENT_ROLE to whatever your template actually uses.
Document creation is asynchronous. The connector docs are explicit that creation is asynchronous and that you should poll document status until it reaches Draft or Error. A newly created document starts as Uploaded and sending before it transitions can fail, so the flow polls first:
The creation payload nests everything under a single request object with a source discriminator, which is the tool's only parameter:
source also accepts markdown and file URLs per the connector documentation, so if you would rather not maintain a PandaDoc template, that path is worth testing against your own account before ruling it out. For offer letters specifically, a template your legal team reviewed is the better artifact regardless.
Four connections, and one of them is easy to skip because it looks like a duplicate.
Create a free account, create a workspace, and copy SCALEKIT_ENV_URL, SCALEKIT_CLIENT_ID, and SCALEKIT_CLIENT_SECRET into .env.
Go to AgentKit > Connections > Create Connection and add PandaDoc MCP. Authorize it as the recruiter.
Add the plain Slack connector. This one sends the timeout and rejection notices.
Add Slack MCP as a second, separate connection. This is the one the approval gate polls. It is a different connection from Step 3, not a reconfiguration of it, and skipping it while leaving REQUIRE_APPROVAL=true fails at startup rather than at the gate.
Add Gmail and authorize it as the recruiter. The candidate's email arrives from their address.
Then configure .env. Set at least one Slack destination, and prefer the hiring manager's user ID over a shared channel, because that is what activates the identity filter on the gate:
Then prompt Claude Code:
The auth helper it generates does one thing most of these scaffolds do not, and it matters for a blocking pipeline: it re-verifies after the user presses Enter.
Pressing Enter is not evidence. An agent that treats it as evidence discovers the missing grant thirty minutes later, at the gate, after a document already exists in PandaDoc.
Nothing here is an LLM call, and everything is checked before a document exists.
Errors accumulate rather than short-circuiting, so a bad request comes back with everything wrong with it at once. Salary normalization accepts 180000, 180k, and $180,000 and emits $180,000, which is what lands in the template token and therefore in the offer letter. A recruiter typing 180 instead of 180000 gets $180 in a legal document, so if you extend this, add a plausibility floor.
Failing to post the request aborts. That is deliberate: an approval flow whose request never arrived should not fall through to sending. The approval message carries the candidate name, role, salary, start date, and a link to the draft, so the manager is approving a specific document rather than a notification.
The process is now blocked, holding a socket open for up to thirty minutes. Plan for that. This is not a fire-and-forget script, and it should not run anywhere that kills long-lived processes.
Distinct exit codes for reject and timeout, and a message back to the same Slack destination in both cases. The distinction is operationally real: 4 means a human decided, 3 means nobody looked. One is a hiring decision and the other is a process failure, and a monitor that collapses them tells you nothing.
Only reachable after approval.
That last branch is the ordering decision the whole flow depends on. PandaDoc sends first, Gmail second. If the e-signature send fails, the candidate email is skipped, because an email announcing an offer that does not exist yet is worse than no email. The draft survives in PandaDoc and is recoverable by hand.
The candidate gets two emails, and the body says so. PandaDoc sends the authoritative signing link; the recruiter's Gmail sends the human note. Telling the candidate to expect both is the difference between a second email that looks legitimate and one that looks like phishing.
--hiring-manager <slack_user_id> routes a specific offer to a different approver than the .env default, and passing it also activates the identity filter for that run. --help works before .env is configured, because argument parsing runs before settings load.
The exit codes are the monitoring surface, and unusually for these agents, most of them are not errors:
3 and 4 are the gate working. Alerting on non-zero indiscriminately will page someone every time a manager declines an offer.
Set REQUIRE_APPROVAL=false and the gate disappears: the agent posts to Slack and sends regardless. That is the notify-only behavior this post opened by arguing against. It exists for teams without a Slack MCP connection, and it is fine for testing the PandaDoc and Gmail path. Ship it that way and you have a notification wearing an approval's clothes.
Route to a person, not a channel. With SLACK_HIRING_MANAGER_ID set, only that user's reaction resolves the gate. Routed to a channel with no manager ID, any member's reaction counts. For compensation approvals, that difference is the entire control.
Verify the reaction parsing on your own workspace. The regex against the MCP server's prose response is the most brittle line in the agent. Post a test message, react, and confirm the gate sees it. A silent parsing failure looks exactly like a manager who has not responded.
Check the first rendered draft by hand. PandaDoc leaves mismatched template tokens blank without erroring. Open the actual Draft in PandaDoc and confirm the salary, name, role, and start date all populated before you trust the pipeline with a real candidate.
Decide what happens on timeout. Thirty minutes is short for an approval that may arrive overnight. Lengthening APPROVAL_TIMEOUT_SECONDS means holding a process open longer; the alternative is treating exit code 3 as a retry signal from whatever triggered the run.
Log the approval, not just the send. The gate produces the most audit-relevant event in the whole flow: a named person authorized a specific compensation figure at a specific time. Slack reactions can be removed afterward and carry no binding to the document. If this needs to survive a compliance review, persist the approval decision alongside the document_id and the reactor's user ID. Audit trails for agent auth covers what those records need to contain, and agent tool observability covers capturing it per tool call.
Do not run this on a short-lived executor. Blocking for up to thirty minutes rules out most serverless defaults.
The approval gate is thirty lines of polling. What makes it a control rather than a formality is everything around it: that the request goes to a named person, that the reaction is checked against that person's user ID, that reject and timeout both stop the flow, and that failing to post the request aborts instead of falling through.
Those properties do not come from the connectors. They come from deciding, before writing the pipeline, which step is irreversible and refusing to let anything reach it by default. Compensation is that step here. In a procurement agent it is the purchase order; in a deployment agent it is production. The pattern transfers, and the connector list is the only thing that changes.
The full code is on GitHub. Clone the repo, configure four connections, and run one test offer 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 repo uses the plain Slack connector for notices and Slack MCP for the gate, with different parameter names on each (channel/text versus channel_id/message). Both connectors expose reaction-reading tools in the current catalog, so a single-connection build is worth evaluating: the plain connector's slack_get_reactions returns structured data instead of prose that needs regex parsing. Confirm your granted scopes support the read before consolidating.
Only if you route approvals to a shared channel that the candidate is in and leave SLACK_HIRING_MANAGER_ID unset. With a manager ID configured, reactions from anyone else are filtered out before the gate evaluates them. Set it.
Nothing reaches the candidate. The document stays in Draft in PandaDoc and can be sent manually or by re-running the flow. The gate holds no state outside the running process, which is the trade for its simplicity: a crash is a clean stop, but a restart begins the approval again from a new Slack message.
Yes. The gate, the validation, and the identity model are provider-agnostic. Swap connectors/pandadoc.py for an equivalent module against whichever connector you use, keeping the same shape: create as draft, return a document ID, send only after approval. Browse the connector catalog for what is available.
Because there is nothing to generate. The offer text lives in a template your legal team reviewed, and the salary, role, and start date come from validated input. Adding a model would introduce variance into a document where variance is a liability.
No. Tokens live in Scalekit's token vault and are refreshed before each tool call, which matters here because the process can sit blocked for thirty minutes between the PandaDoc create and the PandaDoc send. See how to handle token refresh for AI agents for why waiting for a 401 is the wrong pattern.
Pass each recruiter's real user ID as the identifier on every Scalekit call instead of a single RECRUITER_USER from .env, and surface an authorization link whenever their connected account is not ACTIVE. Each recruiter authorizes four connections once. The offer then goes out under their identity, which is the property that makes the audit trail meaningful.