Karya
Integrates with ElevenLabs' voice agent platform to conduct live phone conversations, supporting structured data extraction from call outcomes and webhook callbacks.
Enables email sending through SendGrid, allowing the server to deliver emails such as conversation summaries.
Provides telephony integration for making phone calls and sending SMS messages via Twilio's APIs, with support for long-running call tasks and compliance enforcement.
Adds WhatsApp messaging capabilities, allowing the server to send and manage WhatsApp messages as part of its communication suite.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@KaryaCall Arpit Dash and get his notice period and expected CTC"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Karya
An AI back-office agent that makes phone calls, and the MCP server that gives it hands.
You type this:
you › call Arpit Dash and keep talking until you have his notice period and expected CTCAn AI voice agent phones him, holds a real conversation, and you get this back:
{
"still_interested": true,
"notice_period_days": 60,
"expected_ctc_lpa": 26,
"current_location": "Bengaluru"
}Everything runs end to end with no API keys and no spend — calls are simulated by a scripted provider until you flip one environment variable.
Contents
Related MCP server: @saperly/mcp
What this is
Two packages that are deliberately kept apart:
Package | What it is | Who uses it |
| The capability layer — voice, SMS, WhatsApp, email, CRM, tasks — exposed as MCP tools, resources and prompts. Knows nothing about humans. | Any MCP client: Claude Desktop, your own orchestrator, the ElevenLabs voice agent |
| A Gemini-powered CLI that an operator talks to in English. Knows nothing about Twilio. | You, at a terminal |
That separation is the point. The server is the durable asset — drop it into a larger agent later and every capability comes along. The CLI is one client of it, and there is nothing special about it.
The idea that makes this work
There are two different agents, and conflating them is the usual mistake:
the orchestrator (Gemini, text) plans and talks to you;
the voice agent (ElevenLabs, audio) talks to the candidate, live, on the phone.
We do not build the second one's audio loop. ElevenLabs Agents already owns ASR → LLM → TTS → turn-taking with a native Twilio integration, and — crucially — it can connect to a custom MCP server over HTTP. So the same server serves both: your orchestrator over stdio, the voice agent over HTTP for its in-call tools.
Operator (human, English)
│
▼
┌───────────────────┐
│ @karya/agent │ Gemini Flash · text · plans and reports
└─────────┬─────────┘
│ MCP (stdio)
▼
┌─────────────────────────────────────────────┐
│ @karya/mcp-server │
│ tools · services · provider ports │
└──┬────────────┬─────────────┬───────────────┘
│ │ │
▼ ▼ ▼
Twilio SendGrid ElevenLabs Agents ──── native ──► Twilio Voice
SMS Email (voice agent) │
▲ ▼
MCP (HTTP) ─────┘ Candidate
in-call tools + post-call webhook ──► back into the serverNote the loop at the bottom. The voice agent calls back into this same server mid-call
(save_candidate_field, lookup_contact), and when the call ends ElevenLabs POSTs the
transcript and its extracted fields to our webhook. That is where structured output
comes from — not from regex over a transcript.
Quick start
Requires Node ≥ 20.11 and pnpm.
corepack enable pnpm # or: npm install -g pnpm
pnpm install
pnpm test # 78 tests, no credentials neededSee the whole flow without writing any code
pnpm example:clientThis spawns the server, lists every tool/resource/prompt, looks up a contact, places a (simulated) call, polls it to completion, prints the structured result, and reads the transcript resource. No keys, no spend.
Talk to the agent
Get a free Gemini key at aistudio.google.com/apikey:
cp .env.example .env # then set GEMINI_API_KEY
pnpm agentyou › look up everyone tagged candidate
you › call Arpit Dash and find out his notice period and what he expects to be paid
you › now email him a summary of that conversationYou will see each tool call as it happens:
→ lookup_contact query="Arpit Dash"
✓ Found Arpit Dash (cnt_demo_arpit). Phone: +919876543210.
→ make_phone_call to="cnt_demo_arpit" objective="Screen the candidate…"
✓ Calling Arpit Dash (task tsk_01KZ6P…). Collecting: notice_period_days, expected_ctc_lpa.
→ get_call_result task_id="tsk_01KZ6P…"
✓ Call completed in 13s. Collected: notice_period_days=60, expected_ctc_lpa=26.Use it from Claude Desktop
pnpm buildThen copy the karya block from examples/claude-desktop-config.json
into your Claude Desktop config and restart.
Architecture
Four layers. Dependencies point strictly inward — business logic never sees a vendor.
┌──────────────────────────────────────────┐
│ server/ transport, McpServer wiring │ ← protocol edge
├──────────────────────────────────────────┤
│ tools/ resources/ prompts/ │ ← MCP surface (thin)
│ + core/ registry, middleware │
├──────────────────────────────────────────┤
│ services/ business logic (use cases) │ ← knows only ports
├──────────────────────────────────────────┤
│ providers/ ports (interfaces) │
│ adapters/ memory | elevenlabs | twilio │ ← swappable I/O
└──────────────────────────────────────────┘
cross-cutting: config, logger, errors, store, utilsDesign decisions worth knowing
A phone call is a long-running job, not a request. make_phone_call returns a
task_id in milliseconds. The call then runs for minutes. Anything that awaited the
outcome would hold an MCP request open for ten minutes, break every client timeout, and
lose the result entirely on a reconnect.
Tools are values, not registration calls. Each tool exports a ToolDefinition
object; the registry — not the tool — talks to the MCP SDK. So a tool is unit-testable
by calling execute(input, fakeCtx) with no server, no transport and no SDK mocking.
execute(input, ctx) receives its dependencies. That is the dependency injection.
No DI container, no decorators, no reflection anywhere in this codebase.
A middleware pipeline wraps every invocation — logging/timing → authorization → timeout → validation. Cross-cutting concerns are applied uniformly instead of being re-typed, slightly differently, in each tool.
Two audiences, enforced. The ElevenLabs voice agent is mid-conversation with a
candidate; it must never see send_email. exposeTo hides out-of-audience tools from
tools/list and refuses them at invocation, because "not listed" is not "not callable".
Missing data is reported, never inferred. Every call result carries
missing_fields. Returning nulls invites a model to fill them in; naming the gap
explicitly is what stops a fabricated salary reaching an operator.
Compliance is code, not documentation. TRAI calling hours, DND and consent are
enforced by ComplianceService on every outbound path, and refusals are marked
"do not retry" so a model does not turn one violation into ten.
Project structure
karya/
├─ packages/
│ ├─ shared/ # domain schemas used by BOTH packages
│ │ └─ src/ # primitives · contact · call · task · messaging
│ │
│ ├─ mcp-server/src/
│ │ ├─ index.ts # entrypoint + public API
│ │ ├─ server/ # McpServer wiring, transports, bootstrap, shutdown
│ │ │ └─ transports/ # stdio.ts · http.ts
│ │ ├─ core/ # ToolDefinition, registries, middleware ← the contracts
│ │ ├─ tools/ # one folder per tool
│ │ │ ├─ voice/ messaging/ crm/ scheduling/ tasks/ utilities/
│ │ │ ├─ incall/ # voice-agent-only tools
│ │ │ └─ index.ts # the single tool manifest
│ │ ├─ resources/ # transcripts, KB, policies, contacts, docs
│ │ ├─ prompts/ # recruiter screening, support, booking, …
│ │ ├─ services/ # use cases; depend on ports only
│ │ ├─ providers/
│ │ │ ├─ ports/ # Voice · Sms · WhatsApp · Email · Crm · Knowledge
│ │ │ ├─ adapters/memory/ # ★ full simulator — this is why it's free
│ │ │ ├─ adapters/elevenlabs|twilio|sendgrid/
│ │ │ └─ factory.ts # the ONLY file mapping config → concrete class
│ │ ├─ webhooks/ # ElevenLabs post-call receiver (HMAC verified)
│ │ ├─ store/ # JSON-file repositories, no native deps
│ │ └─ config/ logger/ errors/ utils/
│ │
│ └─ agent/src/
│ ├─ cli.ts # the REPL
│ ├─ loop.ts # Gemini function-calling loop
│ ├─ mcp-client.ts # MCP tools → Gemini declarations
│ ├─ schema-bridge.ts # JSON Schema → Gemini schema ← subtle, well tested
│ └─ system-prompt.ts
│
├─ tests/ unit · tools · validation · integration
├─ examples/ runnable MCP client · Claude Desktop config
└─ docs/ india-compliance · going-live · architectureAdding a tool
Three small files and one import line.
1. src/tools/crm/delete-contact/schema.ts
import { z } from 'zod';
export const DeleteContactInput = z.object({
contact_id: z.string().describe('Id of the contact to delete.'),
});
export const DeleteContactOutput = z.object({
deleted: z.boolean(),
contact_id: z.string(),
});2. src/tools/crm/delete-contact/execute.ts
import type { ToolContext } from '../../../core/tool.js';
import type { z } from 'zod';
import type { DeleteContactInput, DeleteContactOutput } from './schema.js';
export const execute = async (
input: z.infer<typeof DeleteContactInput>,
ctx: ToolContext,
): Promise<z.infer<typeof DeleteContactOutput>> => {
await ctx.services.contacts.delete(input.contact_id);
return { deleted: true, contact_id: input.contact_id };
};3. src/tools/crm/delete-contact/index.ts
import { defineTool } from '../../../core/tool.js';
import { execute } from './execute.js';
import { DeleteContactInput, DeleteContactOutput } from './schema.js';
export const deleteContactTool = defineTool({
name: 'delete_contact',
title: 'Delete a contact',
category: 'crm',
exposeTo: ['operator'],
description: 'Permanently remove a contact. Use when someone asks to be erased.',
inputSchema: DeleteContactInput,
outputSchema: DeleteContactOutput,
annotations: { destructiveHint: true, idempotentHint: true },
execute,
});4. Add it to src/tools/index.ts:
import { deleteContactTool } from './crm/delete-contact/index.js';
export const allTools = [/* … */ deleteContactTool];That is all. Validation, request ids, timing logs, timeouts, audience checks and error
shaping are supplied by the pipeline — write business logic only, and throw any
KaryaError freely.
Why the manifest instead of globbing the directory? Globbing appears to remove that one line, but it breaks bundling and tree-shaking, defeats TypeScript (a broken tool becomes a runtime surprise rather than a compile error), and makes the tool set unknowable without running the app. The single failure it prevents — forgetting the line — is covered by
tests/tools/registry.test.ts, which walks the tree and fails if a tool folder is missing from the array.
Writing a good description
Tool descriptions are read by a language model, and most tool misuse is a description
problem rather than a model problem. State what it does, when to use it, and — most
valuable — when not to. Compare make_phone_call:
Do NOT use this for a text message (use send_sms), and do not call it a second time for the same person while an earlier call is still in progress.
Adding a provider
One adapter file, one line in the factory, credentials in the config schema.
1. Implement the port (src/providers/ports/voice.ts):
export class ExotelVoiceProvider implements VoiceProvider {
readonly name = 'exotel';
async startCall(request: StartCallRequest): Promise<StartCallResponse> {
/* … */
}
async getCall(conversationId: string): Promise<CallResult | null> {
/* … */
}
async endCall(conversationId: string): Promise<void> {
/* … */
}
}2. Register it in src/providers/factory.ts:
case 'exotel':
return new ExotelVoiceProvider({ apiKey: config.exotel.apiKey, logger });3. Add its credentials and requirements to src/config/schema.ts.
No service and no tool changes — nothing in services/ or tools/ has ever heard of
Twilio. This is exactly the path to take if you need an Indian caller ID
(see compliance).
Tools, resources and prompts
Tools (15)
Tool | Category | Audience | Notes |
| voice | operator | Async — returns a |
| voice | operator | Poll until |
| voice | operator | Hang up early |
| messaging | operator | |
| messaging | operator | Higher read rates in India |
| messaging | operator | |
| crm | both | Call this before dialling a name |
| crm | operator | |
| crm | operator | Consent, opt-out, notes |
| scheduling | both | Records intent; does not dial |
| tasks | operator | Any channel |
| tasks | operator | |
| utilities | both | |
| utilities | operator | Returns material; you summarise |
| incall | voice agent | Incremental save during a call |
Resources
URI | What |
| Live capability reference, generated from the registry |
| Compliance rules, rendered from live config |
| Knowledge base article |
| Full transcript and outcome of a call |
| Contact record and preferences |
Prompts
recruitment_screening · customer_support_callback · appointment_booking ·
sales_outreach · payment_reminder · follow_up_call
Run one from the CLI:
/run recruitment_screening candidate="Arpit Dash" role="Senior Backend Engineer"Configuration
Every variable is documented in .env.example. Configuration is
validated at startup and the server refuses to start if anything is wrong — naming
every problem at once rather than one per restart:
Invalid Karya configuration — 3 problem(s) found:
• ELEVENLABS_API_KEY: ELEVENLABS_API_KEY is required when the voice provider is elevenlabs.
• TWILIO_ACCOUNT_SID: TWILIO_ACCOUNT_SID is required when a Twilio provider is enabled.
• KARYA_HTTP_AUTH_TOKEN: … An unauthenticated MCP endpoint exposes your phone and
email accounts to anyone who finds the URL.The single most important variable:
KARYA_PROVIDER_MODE=mock # in-memory adapters, no keys, no spend (default)
KARYA_PROVIDER_MODE=live # real providers, real people, real moneyGoing live
Full walkthrough in docs/going-live.md. In outline:
Twilio — buy a US number (see the India note below). Note the Account SID and Auth Token.
ElevenLabs — create an agent, then add your Twilio number under Phone Numbers (paste SID + auth token; it auto-configures the webhooks). Note the agent id and phone number id.
Expose your server —
cloudflared tunnel --url http://localhost:3000(free).Post-call webhook — point ElevenLabs at
https://<tunnel>/webhooks/elevenlabsand copy the signing secret intoELEVENLABS_WEBHOOK_SECRET. Without this, results never arrive and calls stay "running" forever.In-call tools — add a custom MCP server in ElevenLabs pointing at
https://<tunnel>/mcp/voice-agent, withAuthorization: Bearer <KARYA_HTTP_AUTH_TOKEN>. Use fine-grained approval and auto-approve only the read-safe tools.Set
KARYA_PROVIDER_MODE=liveand start withKARYA_TRANSPORT=http.
Cost
Layer | Choice | Cost |
Orchestrator LLM | Gemini Flash, free tier | $0 (10 req/min, 250/day) |
Voice agent LLM | Point ElevenLabs at Gemini via Custom LLM | avoids the platform LLM markup |
Voice (ASR + TTS + turn-taking) | ElevenLabs Agents | the dominant cost, per-minute |
Telephony | Twilio US → India | ~$1.15/mo number + ~$0.0496/min |
SendGrid | free tier covers low volume | |
Hosting | stdio locally; Cloudflare Tunnel for webhooks | $0 |
The biggest lever is not the vendors — it is mock mode. The full system, including a
scripted phone conversation, runs on in-memory adapters. Develop and test for free; flip
to live only for real calls. Every call result also reports its own estimated cost, and
KARYA_MAX_CALL_DURATION_SECONDS caps the exposure of any single call.
ElevenLabs' exact per-minute rate and free-tier minute allowance are not reproduced here because they change; check their current pricing before you budget.
Compliance
Read docs/india-compliance.md before calling Indian
numbers. The essentials:
⚠️ Twilio has not supported outbound calls from Indian (+91) numbers since 1 August 2024, and its India guidelines state that calls to India may only be placed from non-Indian numbers. Karya therefore dials Indian candidates from a US number, so the recipient sees a foreign caller ID and pickup rates suffer. If a +91 caller ID is a business requirement, you need an Indian provider (Exotel, Plivo India, Knowlarity) — which is a one-adapter change, see adding a provider.
Enforced in code, on every outbound path:
Calling hours — 09:00–21:00 in the recipient's time zone. Outside it, calls are refused, not queued.
Consent — contacts with no lawful basis are blocked. For recruitment the defensible basis is
applied.Opt-out —
doNotContactblocks every channel, with no override.Duration cap — clamped, not honoured, above the configured ceiling.
Not enforced in code, and your responsibility: DLT registration, 140/1600-series numbering, and DND scrubbing.
Development
pnpm typecheck # tsc -b, strict, project references
pnpm lint # eslint, zero warnings
pnpm format # prettier
pnpm test # vitest — 78 tests
pnpm test:watch
pnpm test:coverage
pnpm build # tsup → dist/
pnpm verify # everything above, in orderUseful entry points:
pnpm mcp # server on stdio
pnpm mcp:http # server on HTTP (needs KARYA_HTTP_AUTH_TOKEN)
pnpm agent # the CLI
pnpm example:client # the runnable exampleTesting philosophy
Tests run entirely against in-memory adapters with a frozen clock. That combination is what makes calling-window behaviour testable at all — otherwise it is a test that passes until 21:00 and then starts failing nightly.
The test harness (tests/helpers/harness.ts) stands up the whole system in one call and
invokes tools through the real middleware pipeline and registry — not a parallel test
path that can drift from what ships.
const harness = await createHarness({ now: '2026-08-04T18:00:00Z' }); // 23:30 IST
const error = await harness.callErr('make_phone_call', {/* … */});
expect(error.code).toBe('COMPLIANCE_ERROR');Deployment
stdio (local, Claude Desktop): pnpm build, then point the client at
packages/mcp-server/dist/index.js.
HTTP (hosted, and required for the ElevenLabs voice agent):
KARYA_TRANSPORT=http KARYA_HTTP_AUTH_TOKEN=$(openssl rand -hex 32) pnpm mcp:httpEndpoint | Purpose |
| Liveness. Unauthenticated, deliberately detail-free |
| MCP for the operator audience (bearer auth) |
| MCP for the ElevenLabs voice agent (bearer auth) |
| Post-call results (HMAC verified) |
Sessions are stateless — a fresh McpServer per request, with services and the store
shared and long-lived. There is no session table to leak, expire, or lose on restart.
A Dockerfile is included. Mount a volume at KARYA_DATA_DIR if you want tasks and
transcripts to survive a restart.
License
MIT
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- AlicenseAqualityDmaintenanceMCP server for BubblyPhone that lets AI assistants make real phone calls, manage AI voice agents, buy phone numbers in 30+ countries, and track billing. Supports 20 tools for full telephony control.Last updated2071MIT

@saperly/mcpofficial
Alicense-qualityAmaintenanceMCP server for Saperly that enables AI agents to provision phone numbers, place calls, send SMS, and manage credentials via 36 tools backed by the Saperly API.Last updated5143MIT- Flicense-qualityCmaintenanceAn MCP server enabling AI assistants to make voice calls, send SMS/MMS, and manage group conversations using Twilio and OpenAI.Last updated8
- Alicense-qualityAmaintenancePhone, SMS & email for AI agents. One remote MCP server (Streamable HTTP, OAuth or API-key auth, no local install) exposing call, sms, email, and event tools; also usable via CLI, Python SDK, and OpenAPI. Self-hostable, AGPLv3.Last updated21AGPL 3.0
Related MCP Connectors
Phone, SMS & email for AI agents — one remote MCP endpoint, OAuth login, zero install.
Give AI agents real phone numbers, messages, and voice calls via MCP.
MCP server for AI agents to plan, verify, and deploy Cloudflare-native apps.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/Dashy-E/mcp_server'
If you have feedback or need assistance with the MCP directory API, please join our Discord server