Announcing CIMD support for MCP Client registration
Learn more

Build an Offer Letter Agent: PandaDoc, Slack, and Gmail

TL;DR

  • The agent validates the offer, creates a PandaDoc document from your reviewed template, posts an approval request to the hiring manager in Slack, and polls for a reaction before doing anything the candidate can see. Approved sends for e-signature and emails the link; rejected or timed out leaves the document in Draft.
  • A real gate needs three things a notification does not: read-back (poll the message for reactions), identity (check who reacted, not just which emoji appeared), and fail-closed defaults (exit non-zero on reject and on timeout, never proceed).
  • Every call runs on the recruiter's own connected account. There is no shared HR bot, which matters here because the audit answer to "who sent this offer" should be a person, not a service account.
  • Clone the repo, configure four connections, and run one test offer in under 30 minutes. The offer letter routing template breaks the same flow into six copyable steps.

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.

What Separates a Gate From a Notification

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.

def wait_for_approval( get_reactions: Callable[[], list], approve_emoji: str, reject_emoji: str, poll_interval_seconds: int, timeout_seconds: int, approver_user_id: Optional[str] = None, sleep: Callable[[float], None] = time.sleep, now: Callable[[], float] = time.monotonic, ) -> ApprovalResult: """Poll get_reactions() until approve_emoji or reject_emoji appears, or timeout. get_reactions() must return a list of (emoji, reactor_user_id) tuples, so identity is available here. If `approver_user_id` is set, only reactions from that exact user id count. A channel with multiple members must not let an unrelated reaction resolve the gate. Leave it None when polling a DM, where only the hiring manager and the bot can react at all. `sleep` and `now` are injectable so tests can simulate elapsed time without actually waiting. """ deadline = now() + timeout_seconds elapsed_polls = 0 while now() < deadline: reactions = get_reactions() if approver_user_id is not None: reactions = [(emoji, uid) for emoji, uid in reactions if uid == approver_user_id] emojis = [emoji for emoji, _uid in reactions] if reject_emoji in emojis: logger.warning(f"Offer rejected (reaction: :{reject_emoji}:)") return ApprovalResult(approved=False, timed_out=False, reacted_with=emojis) if approve_emoji in emojis: logger.info(f"Offer approved (reaction: :{approve_emoji}:)") return ApprovalResult(approved=True, timed_out=False, reacted_with=emojis) elapsed_polls += 1 remaining = int(deadline - now()) logger.debug(f"No decision yet, {remaining}s remaining (poll #{elapsed_polls})") sleep(min(poll_interval_seconds, max(remaining, 0))) logger.warning(f"Approval timed out after {timeout_seconds}s with no reaction") return ApprovalResult(approved=False, timed_out=True, reacted_with=[])

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:

# Normalized to None (not "") when unset in .env. Downstream code checks # `is not None` to decide whether to restrict the approval gate to a # specific approver, and an empty string would wrongly activate that # filter and reject every real reaction. self.SLACK_HIRING_MANAGER_ID = os.environ.get("SLACK_HIRING_MANAGER_ID") or None self.SLACK_APPROVALS_CHANNEL = os.environ.get("SLACK_APPROVALS_CHANNEL") or None

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.

Reading Reactions Back: Which Slack Connector Actually Fits

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:

Slack
Slack MCP
Tools
91
19
Auth
OAuth 2.0
OAuth 2.1
Send
slack_send_message(channel, text)
slackmcp_slack_send_message(channel_id, message)
Read reactions
slack_get_reactions(channel, timestamp, full)
slackmcp_slack_get_reactions(channel_id, message_ts)
Reaction response
the item and its reactions, structured
a human-readable sentence
Response envelope
flat payload
{"content": [{"type": "text", "text": "..."}]}

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:

_REACTION_LINE_RE = re.compile(r":(?P[\w+-]+):\s*×\s*\d+.*?\((?P[UW]\w+)\)") def get_reactions(self, channel_id: str, message_ts: str) -> list[tuple[str, str]]: """Return (emoji, reactor_user_id) pairs for a message (empty if none). Reactor identity is required so the approval gate can check that the reaction actually came from the configured hiring manager, not just anyone in the channel. """ parsed = self._call( "slackmcp_slack_get_reactions", {"channel_id": channel_id, "message_ts": message_ts}, ) if parsed is None: return [] text = parsed.get("result", "") if "No reactions found" in text: return [] return _REACTION_LINE_RE.findall(text)

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:

def _call(self, tool_name: str, tool_input: dict) -> Optional[dict]: """Call a slackmcp_* tool and unwrap its {"content": [{"text": ""}]} envelope.""" try: result = self.connect.execute_tool( tool_name=tool_name, identifier=self.user_id, connection_name=self.connection_name, tool_input=tool_input, ) data = result.data or {} content = data.get("content") or [] if not content: return {} text = content[0].get("text", "{}") return json.loads(text) except Exception as e: logger.error(f"SlackMCP call {tool_name} failed: {e}") return None

The Tool Surface: 188 Tools, Eight Calls

Four connections, four catalogs, and a very thin slice of each.

Connector
Auth
Tools in the catalog
Tools this agent wraps
PandaDoc MCP
OAuth 2.1 with DCR
22
pandadocmcp_documents_create, pandadocmcp_documents_send, pandadocmcp_documents_status_get, pandadocmcp_documents_details_get
Slack MCP
OAuth 2.1
19
slackmcp_slack_send_message, slackmcp_slack_get_reactions
Slack
OAuth 2.0
91
slack_send_message
Gmail
OAuth 2.0
56
gmail_send_message

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.

Why Delegated Identity Matters More Here Than Anywhere Else

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:

self.RECRUITER_USER = os.environ.get("RECRUITER_USER") self.PANDADOC_USER = os.environ.get("PANDADOC_USER") or self.RECRUITER_USER self.SLACK_USER = os.environ.get("SLACK_USER") or self.RECRUITER_USER self.GMAIL_USER = os.environ.get("GMAIL_USER") or self.RECRUITER_USER self.SLACKMCP_USER = os.environ.get("SLACKMCP_USER") or self.RECRUITER_USER

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.

result = self.connect.execute_tool( tool_name="slack_send_message", identifier=self.user_id, connection_name=self.connection_name, tool_input={"channel": channel, "text": text}, )

Prerequisites

  • A Scalekit account; the free tier covers this. Credentials come from the dashboard under Settings, API Credentials.
  • A PandaDoc account with a reviewed offer letter template. Not a document. See below.
  • A Slack workspace, and two Scalekit connections: the plain Slack connector and Slack MCP.
  • A Gmail account for the recruiter.
  • Python 3.11 or newer, and scalekit-sdk-python >= 2.12.0.

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.

The PandaDoc Template Is a Hard Requirement

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:

# PandaDoc processes newly-created documents asynchronously (status starts as # "Uploaded" and moves to "Draft" once ready). Sending before that transition # completes can fail, so poll briefly first. logger.debug("Waiting for PandaDoc to finish processing the document") for attempt in range(10): status = pandadoc.get_status(document_id) if status and status.lower() != "uploaded": break time.sleep(1) else: logger.warning( f"Document {document_id} still 'Uploaded' after 10s, continuing anyway" )

The creation payload nests everything under a single request object with a source discriminator, which is the tool's only parameter:

result = self.connect.execute_tool( tool_name="pandadocmcp_documents_create", identifier=self.user_id, connection_name=self.connection_name, tool_input={ "request": { "source": "template", "template_uuid": template_uuid, "name": name, "recipients": [ { "email": candidate_email, "first_name": candidate_first_name, "last_name": candidate_last_name, "role": recipient_role, } ], "tokens": [{"name": k, "value": v} for k, v in tokens.items()], } }, )

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.

How to Set Up Your Connectors in Scalekit

Four connections, and one of them is easy to skip because it looks like a duplicate.

Step 1: Create Your Scalekit Account and Workspace

Create a free account, create a workspace, and copy SCALEKIT_ENV_URL, SCALEKIT_CLIENT_ID, and SCALEKIT_CLIENT_SECRET into .env.

Step 2: Add the PandaDoc MCP Connector

Go to AgentKit > Connections > Create Connection and add PandaDoc MCP. Authorize it as the recruiter.

Step 3: Add the Slack Connector

Add the plain Slack connector. This one sends the timeout and rejection notices.

Step 4: Add the Slack MCP Connector

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.

Step 5: Add the Gmail Connector

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:

# Scalekit SCALEKIT_ENV_URL=https://your-env.scalekit.dev SCALEKIT_CLIENT_ID=skc_xxxxxxxxxxxx SCALEKIT_CLIENT_SECRET=your_secret_here # Recruiter identity. Per-connector overrides only if the recruiter # authorized a service under a different identifier. RECRUITER_USER=recruiter@yourcompany.com PANDADOC_USER= SLACK_USER= GMAIL_USER= SLACKMCP_USER= # Connection names: copy the exact names from AgentKit > Connections PANDADOC_CONNECTOR=pandadocmcp SLACK_CONNECTOR=slack SLACKMCP_CONNECTOR=slackmcp GMAIL_CONNECTOR=gmail # PandaDoc: a TEMPLATE uuid, not a document id PANDADOC_TEMPLATE_UUID= PANDADOC_RECIPIENT_ROLE=Client # Approval routing. Set at least one; the manager ID enables the # identity filter on the gate. SLACK_HIRING_MANAGER_ID=U01234567AB SLACK_APPROVALS_CHANNEL= # Approval gate REQUIRE_APPROVAL=true APPROVE_EMOJI=white_check_mark REJECT_EMOJI=x APPROVAL_POLL_INTERVAL_SECONDS=30 APPROVAL_TIMEOUT_SECONDS=1800 COMPANY_NAME="Acme Inc." LOG_LEVEL=INFO

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 PandaDoc MCP, Slack, Slack MCP, and Gmail. Every call runs as the recruiter, never a shared bot. I need to create a PandaDoc document from a template, post an approval request to Slack, poll that message for reactions until a hiring manager approves or rejects, then send for e-signature and email the candidate. Pass connection_name on every execute_tool call.

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.

def ensure_authorized(connect: Any, connector_name: str, user_id: str) -> None: """Check connector status. Prints a magic link if not yet authorized. After the user confirms they've authorized, re-fetches the connected account and verifies it actually reached ACTIVE. Pressing Enter without completing the OAuth flow must not be treated as success. """ try: resp = connect.get_or_create_connected_account( connection_name=connector_name, identifier=user_id ) if resp.connected_account.status != "ACTIVE": link = connect.get_authorization_link( connection_name=connector_name, identifier=user_id ).link logger.warning( f"Not authorized. {connector_name} ({user_id})\nOpen: {link}" ) input("Press Enter after authorizing...") resp = connect.get_or_create_connected_account( connection_name=connector_name, identifier=user_id ) if resp.connected_account.status != "ACTIVE": raise RuntimeError( f"{connector_name} ({user_id}) is still not ACTIVE " f"(status={resp.connected_account.status}), authorization did not complete" ) logger.info(f"{connector_name} ({user_id}) is ACTIVE") else: logger.info(f"{connector_name} ({user_id}) is ACTIVE") except Exception as e: logger.error(f"Failed to check {connector_name}: {e}") raise

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.

The Pipeline, Step by Step

Step 0: Validate Before Touching Any API

Nothing here is an LLM call, and everything is checked before a document exists.

def validate_offer_request(raw: dict) -> OfferRequest: """Validate and normalize a raw offer request dict. Raises ValidationError on failure.""" errors = [] first_name = (raw.get("candidate_first_name") or "").strip() last_name = (raw.get("candidate_last_name") or "").strip() email = (raw.get("candidate_email") or "").strip() role_title = (raw.get("role_title") or "").strip() base_salary = (raw.get("base_salary") or "").strip() start_date = (raw.get("start_date") or "").strip() if not first_name: errors.append("candidate_first_name is required") if not last_name: errors.append("candidate_last_name is required") if not email: errors.append("candidate_email is required") elif not _EMAIL_RE.match(email): errors.append(f"candidate_email is not a valid email address: {email!r}") if not role_title: errors.append("role_title is required") if not base_salary: errors.append("base_salary is required") elif not _SALARY_RE.match(base_salary.replace("k", "000").replace("K", "000")): errors.append( f"base_salary must be a plain number like '180000' or '$180,000': {base_salary!r}" ) if not start_date: errors.append("start_date is required") else: parsed = _parse_date(start_date) if parsed is None: errors.append(f"start_date must be in YYYY-MM-DD format: {start_date!r}") elif parsed < date.today(): errors.append(f"start_date {start_date!r} is in the past") if errors: raise ValidationError("; ".join(errors)) return OfferRequest( candidate_first_name=first_name, candidate_last_name=last_name, candidate_email=email, role_title=role_title, base_salary=_normalize_salary(base_salary), start_date=start_date, )

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.

Step 1: Post the Request and Block

slack_mcp = SlackMCPConnector(connect, settings.SLACKMCP_USER, settings.SLACKMCP_CONNECTOR) message = slack.format_approval_request( candidate_name=f"{offer.candidate_first_name} {offer.candidate_last_name}", role_title=offer.role_title, base_salary=offer.base_salary, start_date=offer.start_date, document_id=document_id, document_url=document_url, ) posted = slack_mcp.send_message(destination, message) if not posted or not posted.get("message_ts"): logger.error("Failed to post approval request to Slack, aborting") return 1 logger.info( f"Waiting up to {settings.APPROVAL_TIMEOUT_SECONDS}s for a " f":{settings.APPROVE_EMOJI}: or :{settings.REJECT_EMOJI}: reaction" ) result = wait_for_approval( get_reactions=lambda: slack_mcp.get_reactions(destination, posted["message_ts"]), approve_emoji=settings.APPROVE_EMOJI, reject_emoji=settings.REJECT_EMOJI, poll_interval_seconds=settings.APPROVAL_POLL_INTERVAL_SECONDS, timeout_seconds=settings.APPROVAL_TIMEOUT_SECONDS, approver_user_id=hiring_manager_id, )

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.

Steps 1a and b1: The Two Ways This Ends Without a Send

if result.timed_out: slack.send_message( destination, f"Offer for {offer.candidate_first_name} {offer.candidate_last_name} " f"timed out waiting for approval, document left in Draft in PandaDoc.", ) logger.warning("Approval timed out, offer NOT sent to candidate") return 3 if not result.approved: slack.send_message( destination, f"Offer for {offer.candidate_first_name} {offer.candidate_last_name} " f"was rejected, document left in Draft in PandaDoc.", ) logger.warning("Approval rejected, offer NOT sent to candidate") return 4

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.

Steps 1 and 2: Send, Then Email

Only reachable after approval.

sent = pandadoc.send( document_id=document_id, subject=f"Your offer from {settings.COMPANY_NAME}: {offer.role_title}", message=( f"Hi {offer.candidate_first_name}, we're excited to offer you the " f"{offer.role_title} role. Please review and sign at your convenience." ), ) if not sent: logger.error( f"Failed to send document {document_id}, it remains in Draft in PandaDoc. " f"Not emailing the candidate, since the offer was never actually sent." ) return 1

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.

gmail_send_message( connect, settings.GMAIL_USER, connection_name=settings.GMAIL_CONNECTOR, to=offer.candidate_email, subject=f"Your offer from {settings.COMPANY_NAME}: {offer.role_title}", body=( f"Hi {offer.candidate_first_name},\n\n" f"Congratulations! We're excited to offer you the {offer.role_title} role " f"at {settings.COMPANY_NAME}.\n\n" f"Your offer document is ready for review and e-signature:\n" f"{document_url or '(check your email from PandaDoc for the signing link)'}\n\n" f"Please also check your inbox for a separate email from PandaDoc with the " f"secure signing link.\n\n" f"Welcome to the team!\n\n" f"{settings.COMPANY_NAME}" ), )

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.

Running It

pip install -r requirements.txt cp .env.example .env # fill in your values python run_flow.py \ --candidate-first Alex \ --candidate-last Chen \ --email alex.chen@example.com \ --role "Staff Engineer" \ --salary 180000 \ --start-date 2026-08-03

--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:

Code
Meaning
0
Offer sent to the candidate
1
Config missing, invalid input, Scalekit unreachable, or document creation failed
3
Approval timed out, offer not sent
4
Approval rejected, offer not sent
130
Interrupted with Ctrl+C

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.

What to Check Before You Go Live

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.

Conclusion

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.

FAQ

Why does this need two Slack connections?

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.

Can the candidate approve their own offer?

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.

What happens if the agent crashes while blocked at the gate?

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.

Can I use DocuSign or another e-signature provider instead?

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.

Why is there no LLM in this agent?

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.

Do I have to manage token refresh across four connections?

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.

How do I run this for a whole recruiting team?

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.

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.