# 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.

| Tool | Calls | Purpose |
| --- | --- | --- |
| `list_accounts` | `GET /v1/accounts` | Find account IDs and platforms. |
| `create_post` | `POST /v1/posts` | Publish now or schedule. |
| `get_post` | `GET /v1/posts/{id}` | Check per-platform results. |

> [!NOTE]
> 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.

```json title="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.

```ts title="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);
}
```

> [!WARNING]
> `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.

```ts tab="OpenAI"
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 });
  }
}
```

```ts tab="Anthropic"
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](/docs/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](/docs/platform-options).
- **Media needs public URLs.** Models can't upload files. Give them public URLs or `media://` references from your [media library](/docs/media).

## Related

- [Agent Skill](/docs/ai/agent-skill) for coding agents
- [Create a post](/docs/api/create-post) for the full request reference
- [Errors](/docs/errors)
