claude agent sdk
By Beam team · Updated
Give a Claude Agent SDK Agent a Send-Text Tool
Define send_text with the Agent SDK's tool() helper or @tool decorator, wrap it in an in-process SDK MCP server, and register it through mcpServers and allowedTools so Claude can call it by name. Keep Beam's workspace key inside the handler's environment, never in the input schema, and give the schema a required idempotency_key field the handler checks against its own store before calling Beam, since Beam's plain text-send endpoint has no dedup key of its own.
Beam is an independent messaging platform, not an Anthropic or Apple product.
What Does an SDK MCP Server Actually Add Over Calling Beam From Your Own Code?
A developer wiring Claude into a product usually starts by letting their own backend call Beam directly, on a schedule their code controls. Giving a Claude Agent SDK agent its own send_text tool changes that: the model now decides, mid run, when to contact a phone number, and a retry or resumed session can call that tool twice for the same message unless the handler notices.
This matters because of where the decision to send now sits. A webhook-driven backend reads an inbound event, calls Claude for a reply, then calls Beam once, in a fixed order. Hand Claude a send_text tool instead, and the agent loop decides whether a turn warrants a send, so the same intent can trigger a tool call more than once: the model retries after an error, a resumed session replays an earlier plan, or a long-running agent revisits a finished step. None of that is an SDK bug; it's what giving a model its own tool means, and why this tool needs more care than one that only reads data.
An in-process SDK MCP server isn't a separate program your agent connects to over a network. It's a small object living inside your process, holding tool definitions, that you hand to the Agent SDK's query() call so Claude can see and call them during that run (Agent SDK custom tools). Nothing here requires Beam's hosted MCP endpoint at https://beam.aisync.link/mcp; the send_text handler can call Beam's plain REST API itself, the same POST /v1/messages call a webhook backend would make, just triggered by the model's tool call instead of your deterministic pipeline.
That's worth noting, since "MCP server" usually implies a remote connection with its own Bearer token. Here the server runs in your process, the handler is ordinary code you wrote, and Beam only sees a normal workspace-authenticated REST request. If you'd rather Claude reach Beam's hosted tool catalog instead, our MCP rate limit guide covers the request ceiling that catalog enforces per token, which a self-hosted send_text tool doesn't inherit automatically.
How Do You Define send_text: Name, Description, Schema And Handler?
A custom tool is four parts passed to the tool() helper in TypeScript or the @tool decorator in Python: a name Claude uses to call it, a description Claude reads to decide when to call it, an input schema for its arguments, and an async handler that runs when Claude calls it (Agent SDK custom tools). The description earns its keep here: "sends a text message" tells Claude nothing about when a second call is wrong, while "call this once per intended message, and only reuse idempotency_key when retrying the exact same send" gives the model the rule it needs.
Here's a Python version that keeps the dedup check inside the handler, ahead of the Beam call:
import os
from typing import Any
import httpx
from claude_agent_sdk import tool, create_sdk_mcp_server
SENT_KEYS: dict[str, dict[str, Any]] = {} # swap for a real datastore before this touches production
@tool(
"send_text",
"Send one real text message to one phone number through Beam. Call this once per "
"intended message; reuse the same idempotency_key only to retry that exact send.",
{
"type": "object",
"properties": {
"to": {"type": "string", "description": "Recipient phone number, E.164 or 10-digit US"},
"message": {"type": "string", "description": "The exact text to send"},
"idempotency_key": {
"type": "string",
"description": "A stable id for this specific send, such as 'reminder-482-day1'.",
},
},
"required": ["to", "message", "idempotency_key"],
},
)
async def send_text(args: dict[str, Any]) -> dict[str, Any]:
key = args["idempotency_key"]
if key in SENT_KEYS:
cached = SENT_KEYS[key]
return {"content": [{"type": "text", "text": f"Already sent, Beam id {cached['id']}. Not sending again."}]}
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://beam.aisync.link/v1/messages",
headers={"x-api-key": os.environ["BEAM_API_KEY"]},
json={"to": args["to"], "message": args["message"]},
)
if resp.status_code != 200:
return {"content": [{"type": "text", "text": f"Beam rejected the send: {resp.text}"}], "is_error": True}
data = resp.json()
SENT_KEYS[key] = data # record before returning so a crash right here still blocks the next duplicate attempt
return {"content": [{"type": "text", "text": f"Sent. Beam id {data['id']}, status {data['status']}."}]}
beam_server = create_sdk_mcp_server(name="beam", version="1.0.0", tools=[send_text])
Register the server and the tool's fully qualified name, mcp__{server_name}__{tool_name}, with ClaudeAgentOptions before calling query():
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
async def main():
options = ClaudeAgentOptions(
mcp_servers={"beam": beam_server},
allowed_tools=["mcp__beam__send_text"],
)
async for message in query(
prompt="Text the saved test contact to confirm tomorrow's 2pm visit, idempotency key confirm-482-day1.",
options=options,
):
if isinstance(message, ResultMessage) and message.subtype == "success":
print(message.result)
asyncio.run(main())
Diagram: a box labeled "your process" containing a "send_text handler" box connected to a small "idempotency store" box, with one arrow out to "Beam POST /v1/messages" and that arrow continuing to a phone icon, plus a dashed arrow showing a repeat call being caught by the store before it ever reaches Beam.
The handler's error path matters as much as its happy path. An uncaught exception becomes an SDK error result carrying the raw message, and Claude can see that and try again; a handler that catches the failure and returns is_error: True with a composed message gives Claude a clearer reason, here "already sent" instead of a stack trace (Agent SDK custom tools). Since the agent loop can retry on an error result, that composed message does real work: the difference between Claude retrying blindly and Claude knowing the first attempt already succeeded.
Where Should The Beam Workspace Key Actually Live?
Inside the handler's own process environment, read once at call time, never inside the tool's input schema. Anything declared as a schema field is something the model can see and, depending on how your app logs tool calls, something that can end up in a transcript; a credential has no business being a parameter a model fills in. Beam's own developer documentation frames it the same way: give the agent the documentation and the API contract, but inject the workspace key only into a protected server tool, and require approval for live sends.
That requirement has a direct answer in the Agent SDK. Setting a tool's permission_policy to always_ask routes its calls through your canUseTool callback even when pre-approved in allowedTools, exactly the gate you want in front of a tool that can text a real person (Agent SDK custom tools). That field is TypeScript-only as of this writing; a Python handler gets the same protection by checking its own approval state before the Beam call, or by keeping send_text behind a feature flag.
One more credential note: this is the workspace API key from Beam's REST reference, sent as x-api-key, the same credential a webhook backend would use. It differs from the Bearer token Beam issues for its hosted MCP server, which exists for an interactive client calling tools mid conversation rather than a handler you wrote. Beam's iMessage API reference documents that key and the /v1/messages call directly.
Why Does send_text Need Its Own idempotency_key Argument?
Because Beam's plain text-send endpoint doesn't give you one. POST /v1/messages accepts to, message, and a few optional fields for attachments and a sender name; nothing in that body tells Beam "this is the same send as before." Beam does support that pattern elsewhere: a request_key field its MCP tool and email send endpoint both require, where reusing the key returns the original result instead of repeating it. The REST endpoint a self-hosted send_text tool calls isn't one of those surfaces, so calling it twice with the same number and text gets two separate texts, each with its own message id, each going out.
That gap has to be filled in your own handler, and the schema above is where it starts. Requiring idempotency_key forces every call, whether from the model, a retry, or a test script, to carry a stable label checked against its own store before it reaches Beam. Where that key comes from matters too: a timestamp generated fresh on every attempt defeats the point, since a genuine retry would then look like a new send. A key tied to the actual business event, a specific appointment or follow-up step, is what makes a repeat recognizable as a repeat.
It's tempting to assume the SDK's idempotentHint annotation already covers this. It doesn't: the docs describe annotations as metadata, not enforcement, and idempotentHint specifically as informational, something tooling can read, not something that blocks a second call from running your handler's code (Agent SDK custom tools). Set it if accurate, since a correctly built send_text handler is genuinely safe to call twice with the same arguments, but keep it alongside the real dedup check, not instead of it.
How Does This Compare To Beam's Hosted MCP Server And A Webhook Backend?
| Custom send_text tool (this guide) | Beam's hosted MCP server | Webhook plus REST backend | |
|---|---|---|---|
| Who decides when to send | Claude, mid run, when it calls the tool | Claude, mid conversation, through an interactive client | Your own server code, on every inbound event |
| Where the code lives | Your process, as a handler you wrote | Beam's servers, behind a Bearer token | Your process, called directly, no tool-calling layer |
| Credential used | Beam workspace key, read inside the handler | Beam MCP connection token, scoped and time limited | Beam workspace key, same as this guide |
| Built-in dedup on the send call | None; the handler has to add it | Yes, Beam's own request_key pattern | None; the code's fixed call order has to avoid it another way |
| Fits an autonomous agent | Yes, that's the point of giving it a tool | Yes, for an interactive client like Claude Code | No, there's no tool call for a model to decide on |
Nothing here replaces the others outright. A team already running a webhook backend doesn't need to migrate just because this exists; the two solve different problems, one for a deterministic reply pipeline and one for an agent deciding on its own whether a moment calls for a text. If the real question is whether to run any hosted messaging layer at all, our build your own iMessage API guide walks through what self-hosting that layer on a Mac costs against a REST API like Beam's.
What Is The Step By Step Procedure To Test This Before Real Contacts?
Run this against a number you own before send_text ever sees a real contact list.
- Set
BEAM_API_KEYto a key from a workspace with no production contacts, and settoon every test call to your own number. - Call
query()with a prompt that should trigger exactly one tool call, and confirm the agent's transcript shows onetool_useblock formcp__beam__send_text, not two. - Confirm the one text you expected arrives on your phone, and check Beam's own message record for that id rather than trusting the handler's return text alone.
- Call the handler again with the identical
idempotency_keyfrom step two, outside of any agent run, and confirm it returns the cached result with no second text arriving. - Temporarily make the handler raise an exception right after the Beam call succeeds but before it returns, then let Claude retry with the same key, and confirm it returns the cached result instead of sending a second text.
- Omit
idempotency_keyfrom a manual tool call and confirm the schema rejects it outright, rather than the handler silently treating a missing key as a new send. - If you've wired a
permission_policyor acanUseToolgate, confirm a send attempt pauses for approval before it reaches Beam, not just before the model's final answer. - Grep whatever you log from this run for the literal value of
BEAM_API_KEYand confirm it never appears: a key inside a schema field would show up here, a key read from the environment inside the handler won't. - Only after steps one through eight pass clean, point the same tool at a small slice of real contacts, not the full list.
That list covers the tool's own correctness. For the broader pre-launch pass, delivery confirmation beyond the queued response, rate limit behavior under a burst, and a dry run against STOP handling, our pre-launch testing guide walks through the fuller sequence before any Claude texting agent touches a live number.
Before you connect
What do teams ask before they connect?
Does the Agent SDK's idempotentHint annotation stop send_text from texting someone twice?
No. idempotentHint is metadata the SDK docs describe as informational only, something Claude and other tooling can read, not a mechanism that blocks a second network call. A handler marked with that hint still runs its own code every time it's invoked, so the dedup check has to live inside the handler itself.
How long does it take to add a send_text tool to an existing Claude Agent SDK project?
Defining the tool, wrapping it in an SDK MCP server and registering it with allowed_tools is usually under an hour once you already have a Beam workspace key. Building a real idempotency store, a permission gate for live sends and the test pass against your own number is the part that takes a day or two, and it's the part worth not skipping.
Does sending through a custom send_text tool still require A2P 10DLC registration and consent handling?
Yes where it applies. iMessage and RCS delivered through Beam avoid the carrier A2P 10DLC registration process that governs local SMS numbers, while SMS fallback, used when a recipient's device doesn't support RCS or blue bubbles, stays subject to the applicable carrier A2P requirements. A contact who texts STOP has to actually stop receiving messages no matter which code path triggered the send.
Can a send_text tool built this way reach contacts synced from GoHighLevel or another CRM?
Yes, as long as the number you pass to the tool belongs to a contact already synced into the same Beam workspace your API key authenticates against. The tool itself doesn't need to know about the CRM; it just needs a phone number and a message, and Beam keeps that contact's history in one thread regardless of which system originated the request.
Keep exploring