AI & Integrations

Function Calling

Tool definitions and a minimal executor that let OpenAI or Claude models publish through Relaycast.

Function calling (also called tool use) lets a language model decide when to call your code. You describe a few tools with JSON Schema, the model replies with a tool call, your code runs the matching Relaycast request, and you send the result back to the model.

This page gives you three tools that cover the whole publishing flow, an executor that runs them, and the loop for OpenAI and Anthropic models.

ToolCallsPurpose
list_accountsGET /v1/accountsFind account IDs and platforms.
create_postPOST /v1/postsPublish now or schedule.
get_postGET /v1/posts/{id}Check per-platform results.

The model never sees your access key. It only produces tool arguments; your executor adds the x-access-key header when it calls Relaycast.

Tool definitions

These schemas are provider-neutral. The examples below wrap them in each provider's format.

tools.json
[
  {
    "name": "list_accounts",
    "description": "List the social media accounts connected to Relaycast. Returns id, platform, handle, displayName and status for each. Call this first to get account IDs; only accounts with status 'active' can be posted to.",
    "parameters": { "type": "object", "properties": {}, "additionalProperties": false }
  },
  {
    "name": "create_post",
    "description": "Publish or schedule a social media post to one or more accounts. Confirm the final text, accounts and time with the user before calling. Returns the created post with one target per account.",
    "parameters": {
      "type": "object",
      "properties": {
        "content": { "type": "string", "description": "Post text shared by all targets (1-10,000 characters; platforms have lower limits, e.g. Bluesky 300)." },
        "targets": {
          "type": "array",
          "minItems": 1,
          "maxItems": 20,
          "description": "Accounts to publish to.",
          "items": {
            "type": "object",
            "properties": {
              "accountId": { "type": "string", "description": "Account id from list_accounts." },
              "content": { "type": "string", "description": "Optional text that replaces the shared content for this account only." },
              "options": { "type": "object", "description": "Optional platform-specific options, e.g. { \"privacyLevel\": \"PUBLIC_TO_EVERYONE\" } for TikTok or { \"title\": \"...\" } for YouTube." }
            },
            "required": ["accountId"]
          }
        },
        "media": {
          "type": "array",
          "maxItems": 10,
          "description": "Optional media. URLs must be public https URLs the platform can download.",
          "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 date-time with timezone, e.g. 2026-10-12T09:00:00Z. Omit to publish immediately." }
      },
      "required": ["content", "targets"]
    }
  },
  {
    "name": "get_post",
    "description": "Get a post and the publish result for each target: status (queued, publishing, published, failed), externalUrl when live, errorMessage when failed.",
    "parameters": {
      "type": "object",
      "properties": { "postId": { "type": "string", "description": "The post id returned by create_post." } },
      "required": ["postId"],
      "additionalProperties": false
    }
  }
]

The create_post tool leaves out publishNow and lets the executor set it when scheduledFor is missing. That's one less thing for the model to get wrong.

The executor

The executor maps each tool call to an HTTP request. It returns the JSON response, including error bodies, so the model can read validation errors and fix its arguments.

relaycast-tools.ts
const API = "https://social-publish-api.onrender.com";
const KEY = process.env.RELAYCAST_API_KEY!;

type ToolInput = Record<string, any>;

async function call(method: string, path: string, body?: unknown, idempotencyKey?: string) {
  const res = await fetch(`${API}${path}`, {
    method,
    headers: {
      "x-access-key": KEY,
      ...(body ? { "Content-Type": "application/json" } : {}),
      ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json().catch(() => ({}));
  return { status: res.status, ...json };
}

/**
 * Runs one tool call and returns a JSON string for the model.
 * `callId` is the provider's tool-call id, reused as the idempotency key so a
 * repeated execution of the same call never creates a second post.
 */
export async function runTool(name: string, input: ToolInput, callId: string): Promise<string> {
  let result: unknown;
  switch (name) {
    case "list_accounts":
      result = await call("GET", "/v1/accounts");
      break;
    case "create_post": {
      const { scheduledFor, ...rest } = input;
      const body = scheduledFor ? { ...rest, scheduledFor } : { ...rest, publishNow: true };
      result = await call("POST", "/v1/posts", body, `agent-${callId}`);
      break;
    }
    case "get_post":
      result = await call("GET", `/v1/posts/${encodeURIComponent(String(input.postId))}`);
      break;
    default:
      result = { error: { code: "unknown_tool", message: `No tool named ${name}` } };
  }
  return JSON.stringify(result);
}

create_post publishes publicly and costs credits. In production, pause before running it: show the arguments to a person and only call runTool once they approve. Also cap the number of loop iterations so a confused model can't post repeatedly.

Run the loop

Both providers follow the same pattern: send the conversation with the tools, run any tool calls the model makes, append the results, and repeat until the model answers in plain text. Install the provider's own SDK (npm install openai or npm install @anthropic-ai/sdk); Relaycast itself is called with plain fetch in the executor above.

import OpenAI from "openai";
import tools from "./tools.json";
import { runTool } from "./relaycast-tools";

const openai = new OpenAI(); // reads OPENAI_API_KEY

const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
  { role: "system", content: "You publish social media posts with Relaycast. Confirm before posting." },
  { role: "user", content: "Post 'We just shipped dark mode' to our LinkedIn and Bluesky now." },
];

for (let turn = 0; turn < 8; turn++) {
  const completion = await openai.chat.completions.create({
    model: process.env.OPENAI_MODEL!, // any tool-capable model
    messages,
    tools: tools.map((t) => ({ type: "function" as const, function: t })),
  });
  const message = completion.choices[0]!.message;
  messages.push(message);

  if (!message.tool_calls?.length) {
    console.log(message.content);
    break;
  }
  for (const toolCall of message.tool_calls) {
    if (toolCall.type !== "function") continue;
    const output = await runTool(toolCall.function.name, JSON.parse(toolCall.function.arguments || "{}"), toolCall.id);
    messages.push({ role: "tool", tool_call_id: toolCall.id, content: output });
  }
}
import Anthropic from "@anthropic-ai/sdk";
import tools from "./tools.json";
import { runTool } from "./relaycast-tools";

const anthropic = new Anthropic(); // reads ANTHROPIC_API_KEY

const messages: Anthropic.MessageParam[] = [
  { role: "user", content: "Post 'We just shipped dark mode' to our LinkedIn and Bluesky now." },
];

for (let turn = 0; turn < 8; turn++) {
  const response = await anthropic.messages.create({
    model: "claude-opus-5-5",
    max_tokens: 16000,
    system: "You publish social media posts with Relaycast. Confirm before posting.",
    tools: tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters as Anthropic.Tool.InputSchema })),
    messages,
  });
  messages.push({ role: "assistant", content: response.content });

  if (response.stop_reason !== "tool_use") {
    for (const block of response.content) if (block.type === "text") console.log(block.text);
    break;
  }
  // Return every tool result in a single user message.
  const results: Anthropic.ToolResultBlockParam[] = [];
  for (const block of response.content) {
    if (block.type !== "tool_use") continue;
    const output = await runTool(block.name, block.input as Record<string, any>, block.id);
    results.push({ type: "tool_result", tool_use_id: block.id, content: output });
  }
  messages.push({ role: "user", content: results });
}

With tools.json imported as a module, enable resolveJsonModule in your tsconfig.json, or paste the array into a .ts file instead.

Tips

  • Return errors to the model. A 400 validation_error has details keyed by account ID with the failing field and message, such as a caption that's too long for Bluesky. Models usually fix this on the next turn, for example by adding a shorter per-account content.
  • Don't let the model invent IDs. The tool descriptions tell it to call list_accounts first. You can also prefetch accounts and put them in the system prompt to save a round trip.
  • Publishing is asynchronous. create_post returns targets as queued. If the user wants to know the outcome, the model can call get_post a little later, or you can listen for webhook notifications outside the loop.
  • Add options per platform carefully. If your agent posts to TikTok, mention in the system prompt that TikTok targets need options.privacyLevel. See Platform Options.
  • Media needs public URLs. Models can't upload files. Give them public URLs or media:// references from your media library.

This page is also available as Markdown.