Language models are good at the parts of social publishing people find tedious: rewriting one announcement for five audiences, trimming a caption to fit, picking a sensible time. What they cannot do on their own is press "publish". Function calling (also called tool use) closes that gap: you describe a few functions to the model, it decides when to call them, and your code carries out the call.
Relaycast is a good fit for this because the whole publishing surface is a handful of REST endpoints with one consistent request shape. This article shows how to expose it to an agent, and, just as importantly, how to keep the agent from doing something you did not intend.
The three tools an agent needs
You do not need to wrap the whole API. Three tools cover nearly every publishing task:
| Tool | Endpoint | Why the agent needs it |
|---|---|---|
list_accounts | GET /v1/accounts | To turn "post to our LinkedIn and Bluesky" into account IDs. |
create_post | POST /v1/posts | To publish now or schedule for later. |
get_post | GET /v1/posts/{id} | To check the per-platform result and report back. |
Here they are as JSON Schema tool definitions. Most model providers accept this shape with minor renaming (some call the schema field parameters, others input_schema):
[
{
"name": "list_accounts",
"description": "List the connected social accounts. Returns id, platform, handle, displayName and status for each. Only accounts with status 'active' can be published to.",
"input_schema": { "type": "object", "properties": {} }
},
{
"name": "create_post",
"description": "Publish or schedule a post to one or more connected accounts. Content is validated per platform; validation errors are returned grouped by account id so you can fix and retry. Each target costs one credit.",
"input_schema": {
"type": "object",
"properties": {
"content": { "type": "string", "description": "Shared post text." },
"targets": {
"type": "array",
"items": {
"type": "object",
"properties": {
"accountId": { "type": "string" },
"content": { "type": "string", "description": "Optional text for this account only." },
"options": { "type": "object", "description": "Optional platform-specific options." }
},
"required": ["accountId"]
}
},
"media": {
"type": "array",
"items": {
"type": "object",
"properties": {
"url": { "type": "string" },
"type": { "type": "string", "enum": ["image", "video", "document"] },
"altText": { "type": "string" }
},
"required": ["url", "type"]
}
},
"scheduledFor": { "type": "string", "description": "ISO 8601 time with offset, e.g. 2026-10-06T09:00:00-04:00." }
},
"required": ["content", "targets", "scheduledFor"]
}
},
{
"name": "get_post",
"description": "Get a post and the result for each target: status, externalUrl, errorMessage.",
"input_schema": {
"type": "object",
"properties": { "postId": { "type": "string" } },
"required": ["postId"]
}
}
]Notice that create_post requires scheduledFor and does not offer publishNow. That is a deliberate guardrail, covered below.
Executing the calls
When the model asks to call a tool, your code makes the HTTP request and hands the result back. The dispatcher is short:
import json, os, uuid, requests
API = os.environ["RELAYCAST_API_URL"]
HEADERS = {"x-access-key": os.environ["RELAYCAST_API_KEY"]}
def run_tool(name: str, args: dict, run_id: str) -> str:
if name == "list_accounts":
r = requests.get(f"{API}/v1/accounts", headers=HEADERS)
elif name == "get_post":
r = requests.get(f"{API}/v1/posts/{args['postId']}", headers=HEADERS)
elif name == "create_post":
r = requests.post(
f"{API}/v1/posts",
headers={**HEADERS, "Idempotency-Key": f"agent-{run_id}-{uuid.uuid5(uuid.NAMESPACE_OID, json.dumps(args, sort_keys=True))}"},
json=args,
)
else:
return json.dumps({"error": f"unknown tool {name}"})
# Return errors to the model too: validation messages are what it needs to fix the request.
return json.dumps(r.json())import { createHash } from "node:crypto";
const API = process.env.RELAYCAST_API_URL!;
const headers = { "x-access-key": process.env.RELAYCAST_API_KEY! };
export async function runTool(name: string, args: any, runId: string): Promise<string> {
let res: Response;
if (name === "list_accounts") res = await fetch(`${API}/v1/accounts`, { headers });
else if (name === "get_post") res = await fetch(`${API}/v1/posts/${encodeURIComponent(args.postId)}`, { headers });
else if (name === "create_post") {
const key = `agent-${runId}-${createHash("sha256").update(JSON.stringify(args)).digest("hex").slice(0, 32)}`;
res = await fetch(`${API}/v1/posts`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(args),
});
} else return JSON.stringify({ error: `unknown tool ${name}` });
return JSON.stringify(await res.json());
}Plug run_tool into your provider's tool-use loop: send the conversation and tool definitions, run any tool calls the model returns, append the results, and repeat until the model answers in plain text.
Why the API's errors help the model
Agents work best when failures are specific. Relaycast validates every target before anything is queued, and returns problems as structured data:
{
"error": {
"code": "validation_error",
"message": "Content is not valid for some platforms",
"details": {
"BLUESKY_ACCOUNT_ID": [{ "field": "content", "message": "Bluesky allows 300 characters (this post has 342)" }]
}
}
}A model that sees this will usually shorten the text for that one account (using the target's content) and call create_post again, without any special handling on your side. Because nothing was published and no credits were spent on the failed attempt, letting the model retry is cheap.
The same goes for results. get_post returns each target's status, externalUrl and errorMessage, which is exactly what the agent needs to say "LinkedIn and Bluesky are live, here are the links; Instagram failed because the image was not a JPEG."
Guardrails worth having
An agent that can publish is an agent that can publish the wrong thing. These habits keep a human in charge without making the agent useless.
Schedule, do not publish immediately
Requiring scheduledFor (and leaving publishNow out of the tool schema) means every agent-created post sits in the queue first. It appears on the Posts page as scheduled, where a person can review it and delete it before it goes out. Pair this with a notification rule for post.scheduled under Settings → Notifications, so someone is told every time the agent queues something.
If you want an even firmer check, have your dispatcher refuse a scheduledFor less than, say, 30 minutes away.
Confirm before calling
For interactive assistants, show the user the exact create_post arguments and ask for a yes before running the call. The arguments are already a complete, readable request body.
Use idempotency keys
Agents retry. Models sometimes call the same tool twice, and tool-use loops get restarted after errors. Deriving the Idempotency-Key from the run and the arguments, as in the dispatcher above, means a repeated identical call returns the original post instead of creating a second one.
Limit what the agent can reach
Give the agent its own API key, so its requests are easy to pick out on the Logs page and you can revoke it without affecting anything else. If the agent should only post for one brand, filter list_accounts results in your dispatcher (for example by social group) so other accounts are never offered to the model.
Mind the credits
Each target costs one credit. If the agent runs unattended, cap the number of targets per call in your dispatcher, and consider a daily limit. Usage alert emails under Settings → General can warn you when credits run low.
A worked conversation
User: Announce our new CSV export on LinkedIn and Bluesky tomorrow at 9am New York time. Keep the Bluesky one short.
The agent calls list_accounts, picks the active LinkedIn and Bluesky accounts, and drafts two versions. It calls create_post with the shared text for LinkedIn, a shorter content on the Bluesky target, and scheduledFor set to 9:00 with the correct New York offset for that date. Your app shows the user the arguments, the user confirms, and the post is queued. The next morning, a post.published webhook arrives and the agent (or your app) reports the two links.
Going further
The function calling guide has the full tool definitions, including platform options. If your agent framework reads documentation directly, the docs are also available as plain Markdown and llms.txt; see the AI & integrations section. To get started, create a project and an API key for your agent.
Ready to build?
Get an access key and publish your first post in minutes.