Give Your Voice Agent Hands: Let It Act Across Sales and Marketing
The call ends. The admin starts. Maya is an account executive. She just finished a good call with Priya at Larkspur Freight. They agreed on pricing in principle, Priya wants a recap in writing, there's a follow-up on T
The call ends. The admin starts.
Maya is an account executive. She just finished a good call with Priya at Larkspur Freight. They agreed on pricing in principle, Priya wants a recap in writing, there's a follow-up on Tuesday, and Larkspur's CMO should hear about the enterprise plan.
Now Maya has fifteen minutes before her next call, and four tools to update:
- HubSpot: log the call, move the deal to negotiation.
- Apollo: find the CMO, add her as a contact, put her in the enterprise sequence.
- Google Calendar: book Tuesday.
- Gmail: write the pricing recap.
Her team already has a voice agent. It's good at talking. It can summarize the call and suggest next steps. But it can't touch any of those four tools, so Maya does all of it by hand, again.
The obvious fix is to give the agent access to those apps. With voice, that's harder than it sounds:
- There's no screen mid-call. If HubSpot's connection expired, the agent can't pop up an OAuth consent page while Maya is talking to it.
- Every tool slows the reply. Each tool schema goes into the model's context on every turn. In a chat app, a second of extra thinking is fine. In a voice call, it's an awkward silence.
- Voice gets misheard. "Draft Priya the recap" can become "send Priya the recap." One wrong word and a half-finished email reaches a prospect.
- Maya can't see what changed. In a chat UI she'd see the tool calls scroll by. On a call, she only knows what the agent tells her.
This article builds a voice agent that handles that whole list for one rep, with her own access, and tells her what it did.
The job: one sentence, five actions
Here's the job, in Maya's words:
When I hang up with a prospect, I want to say what happened and have the CRM, follow-up and outreach updated before my next call.
So after the call she says one thing:
"Log the Larkspur Freight call, move the deal to negotiation, book a follow-up for Tuesday, draft Priya the pricing recap, and add their CMO to the enterprise sequence."
That sentence turns into five actions, each backed by a tool from the connector's docs:
| What Maya asked | App | Tools |
|---|---|---|
| Log the call, move the deal | HubSpot |
hubspot_deals_search, hubspot_call_log, hubspot_deal_update
|
| Find the CMO and add her to the sequence | Apollo |
apollo_enrich_contact, apollo_create_contact, apollo_add_contacts_to_sequence
|
| Book Tuesday's follow-up | Google Calendar | googlecalendar_create_event |
| Draft the pricing recap | Gmail | gmail_create_draft |
| Tell her what changed | (the agent) | none, it reads back what the tools returned |
Two choices here are deliberate.
The recap is saved as a draft, not sent. gmail_create_draft exists next to gmail_send_message, and for a voice agent the draft is the right default. If a word was misheard, it sits in Maya's drafts folder instead of Priya's inbox.
And the agent reads back what it did: "Logged the call, moved Larkspur to Negotiation, booked Tuesday at 10, saved the recap as a draft, and added Dana Okafor to the enterprise sequence." On a voice call, that read-back is the only audit trail Maya sees.
Building it yourself
You can wire this up directly. Here's what that takes for a team of 30 reps.
Three OAuth apps, four tools. HubSpot, Apollo and Google each need an OAuth app, a consent flow, scopes, an encrypted token store per rep, and refresh logic. Google Calendar and Gmail can share one Google app, but their scopes still need to be right.
Token handling that works mid-call. A rep's HubSpot token expires at 2 p.m. The voice agent tries to log a call at 2:05 and fails. Your code needs to refresh before the call, and if refresh isn't possible, say so out loud and get a link to the rep somewhere other than the call.
A tool list small enough to talk with. The four connectors together expose hundreds of tools. HubSpot's connector alone has 457 in Scalekit's catalog, 80 of them destructive. You'll hand-pick eight, write their schemas, and keep them in sync as the APIs change.
Guardrails against mishearing. You'll need to make sure no delete, archive or send tool is reachable, however the request was transcribed.
A record of every action, per rep. When a deal moves stage and nobody remembers why, you need to know which rep's agent did it, when, and what came back.
None of that is hard on its own. Together it's the bulk of the project, and every new app and new rep makes it bigger. The voice part, it turns out, is the easy part.
Building it with Scalekit and Vapi
This version extends Scalekit's Vapi voice assistant cookbook, which shows the pattern with Google Calendar, to HubSpot, Apollo and Gmail. Vapi handles speech and the conversation. Scalekit sits between the agent and the apps: it holds each rep's connections, exposes only the tools this role needs, and logs every call.
Step 1: Each rep connects their apps once
Before Maya's first call, she connects HubSpot, Apollo and Google, for example from a settings page in your app. Your backend asks Scalekit for her connected account on each connection and, if it isn't active yet, gets an authorization link to send her:
import os
from scalekit import ScalekitClient
scalekit = ScalekitClient(
env_url=os.environ["SCALEKIT_ENV_URL"],
client_id=os.environ["SCALEKIT_CLIENT_ID"],
client_secret=os.environ["SCALEKIT_CLIENT_SECRET"],
)
actions = scalekit.actions
# Use the connection names you created in the Scalekit dashboard
CONNECTIONS = ["hubspot", "apollo", "googlecalendar", "gmail"]
def links_needed(rep_id: str) -> dict:
"""Return an authorization link for every connection that isn't active."""
links = {}
for name in CONNECTIONS:
account = actions.get_or_create_connected_account(
connection_name=name, identifier=rep_id
).connected_account
if account.status != "ACTIVE":
links[name] = actions.get_authorization_link(
connection_name=name, identifier=rep_id
).link
return links
rep_id is your app's ID for Maya. Her tokens stay in Scalekit, encrypted, and get refreshed there. Neither your voice app nor the model ever sees them.
Step 2: A Virtual MCP server with only this role's tools
The four connectors expose hundreds of tools between them. Maya's agent gets eight. You define that once, as a Virtual MCP server for the role:
from scalekit.actions.models.mcp_config import McpConfigConnectionToolMapping as Tools
response = actions.mcp.create_config(
name="ae-post-call",
description="Post-call updates for account executives",
connection_tool_mappings=[
Tools(connection_name="hubspot", tools=[
"hubspot_deals_search", "hubspot_call_log", "hubspot_deal_update",
]),
Tools(connection_name="apollo", tools=[
"apollo_enrich_contact", "apollo_create_contact",
"apollo_add_contacts_to_sequence",
]),
Tools(connection_name="googlecalendar", tools=["googlecalendar_create_event"]),
Tools(connection_name="gmail", tools=["gmail_create_draft"]),
],
)
MCP_CONFIG_ID = response.config.id
MCP_SERVER_URL = response.config.mcp_server_url
Look at what's not on the list. None of HubSpot's 80 destructive tools, neither of Apollo's two (apollo_archive_sequence, apollo_remove_records_from_list), none of Google Calendar's six, and none of Gmail's eight delete and trash tools. gmail_send_message isn't there either. Whatever the transcription says, the agent can't delete, archive, remove or send. And eight schemas instead of hundreds keeps every turn short, which matters when someone is waiting for a spoken reply.
Step 3: Pass the rep's identity at the start of every call
When Maya taps "Start call," your backend checks her connections, then mints a session token for her on that server. The token is bound to her identifier, so every tool call in this conversation runs with her access and nobody else's.
from datetime import timedelta
@app.post("/api/voice/start")
def start_call(user=Depends(current_user)):
rep_id = user.id # from your own auth, never from the browser
missing = links_needed(rep_id)
if missing:
notify_rep(rep_id, missing) # e.g. Slack DM or email with the links
return {"status": "reconnect", "apps": list(missing)}
session = actions.mcp.create_session_token(
mcp_config_id=MCP_CONFIG_ID,
identifier=rep_id,
expiry=timedelta(minutes=30),
)
return {
"status": "ok",
"scalekitToken": session.token,
"sequenceId": user.team.enterprise_sequence_id,
"mailboxId": user.apollo_mailbox_id,
}
This is how a lapsed connection gets handled with no screen. If Maya's HubSpot grant has expired, the call doesn't start silently broken. Your app tells her which app to reconnect and sends the link to wherever she'll see it. If a connection lapses mid-call, the tool call fails, and the agent's instructions (Step 4) tell it to say so out loud rather than guess.
The session token lasts 30 minutes here (Scalekit allows 60 seconds to 24 hours). It can't be refreshed, so each call gets a new one.
Step 4: Point the Vapi assistant at the server
In Vapi, add an MCP tool to the assistant. Vapi's MCP tool takes a server URL and headers, and headers can use variables:
{
"type": "mcp",
"server": {
"url": "<MCP_SERVER_URL>",
"headers": { "Authorization": "Bearer {{scalekitToken}}" }
}
}
Then start the call from the browser with that rep's values:
const res = await fetch("/api/voice/start", { method: "POST" }).then((r) => r.json());
if (res.status === "reconnect") {
showBanner(`Reconnect ${res.apps.join(", ")}. We've sent you a link.`);
} else {
vapi.start(ASSISTANT_ID, {
variableValues: {
scalekitToken: res.scalekitToken,
sequenceId: res.sequenceId,
mailboxId: res.mailboxId,
},
});
}
Vapi discovers the eight tools when the call starts. The system prompt does the rest:
You help an account executive update their tools right after a sales call.
- Before writing anything, repeat back names, dates and the deal you found, and wait for a yes.
- Find the deal with hubspot_deals_search before updating it.
- Emails are always drafts. You cannot send email.
- Add contacts to sequence {{sequenceId}}, sending from mailbox {{mailboxId}}.
- If a tool says a connection needs to be re-authorized, say which app
and tell the rep a reconnect link is on its way. Do not retry.
- When you're done, read back every change you made, one line each.
A note from the HubSpot docs: hubspot_call_log takes the call title, body, direction, duration and status. Check in the Scalekit playground how the logged call gets associated with the deal in your portal before you rely on it.
Step 5: Every action is logged against the rep
Every tool call goes through Scalekit, so each one is logged with the tool, the rep's identifier, timing and result. If Larkspur's deal stage looks wrong next week, you can see exactly which call moved it, from whose agent, and what HubSpot returned. Failures are marked as connector errors (HubSpot throttling, a scope it refused) or API errors (your setup).
Prefer LiveKit?
Scalekit's LiveKit cookbook does the same identifier hand-off a different way. Your server reads the signed-in user and passes the identifier in the agent's dispatch metadata, and the worker calls executeTool for each tool it defines. The cookbook notes that @livekit/agents 1.4.11 has no MCP toolset, so there you scope the tools by choosing which ones to wrap.
Both cookbooks have repos you can clone: Vapi and LiveKit.
When you don't need this
If the voice agent is for you alone, running on your own HubSpot and Google accounts, you can skip the integration layer. Put your API keys in environment variables, write the eight tools by hand, and you're done in an afternoon.
It starts to matter when the second rep wants their own access, when a connection expires mid-quarter, or when someone asks which agent moved a deal. That's when per-rep connections, a scoped tool list and per-call logs stop being nice to have.
Getting started
To try it with your own accounts, start with the Scalekit quickstart, then add the HubSpot, Apollo, Google Calendar and Gmail connections from the dashboard.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.





