← All posts
Articles

Let an AI Agent Publish for You With Function Calling

Give a language model three tools backed by the Relaycast API so it can look up accounts, publish or schedule posts and check results, with guardrails that keep a human in charge.

Relaycast Team · September 29, 2026 · 4 min read

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:

ToolEndpointWhy the agent needs it
list_accountsGET /v1/accountsTo turn "post to our LinkedIn and Bluesky" into account IDs.
create_postPOST /v1/postsTo publish now or schedule for later.
get_postGET /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):

tools.json
[
  {
    "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.

#ai#agents#function-calling

Ready to build?

Get an access key and publish your first post in minutes.

Related posts