Last updated: September 2026

Give an AI agent a LinkedIn and Bluesky tool

Define one tool, post_to_social, with a platforms enum of x, bluesky and linkedin, and have it call POST https://opentweet.io/api/v1/posts with your ot_ key. The same schema works in the OpenAI Agents SDK, LangChain and LangGraph, CrewAI and the Vercel AI SDK. LinkedIn posts go to your personal profile through the official API.

One call can target any subset of the three networks and returns a result per network. If your agent already speaks MCP, skip the code and use the hosted MCP server instead.

7-day trial. $11.99/mo after, Bluesky and LinkedIn on every plan.

1. Connect the accounts and get a key

Sign up, then connect each network once in Settings > Accounts. LinkedIn uses LinkedIn sign-in, Bluesky uses atproto OAuth (no App Password), and X uses X OAuth. Your agent never sees any of those credentials.

Open the API section and generate a key. It starts with ot_. Store it as an environment variable:

terminal
export OPENTWEET_API_KEY="ot_your_key_here"

2. The tool definition

This is the whole contract, as a JSON schema. platforms is required on purpose: a post that omits it follows the account's auto cross-post setting, and an agent writing unattended should never leave that to chance. Each framework below produces this same shape, either from type hints or by taking the schema as is.

post_to_social.json
{
  "name": "post_to_social",
  "description": "Create, schedule or publish one post on X, Bluesky and/or LinkedIn through OpenTweet. With neither scheduled_date nor publish_now, the post is saved as a draft for human review.",
  "parameters": {
    "type": "object",
    "properties": {
      "text": {
        "type": "string",
        "description": "The post body. Limits: X 280 characters (25,000 on Premium), Bluesky 300, LinkedIn 3,000."
      },
      "platforms": {
        "type": "array",
        "items": { "type": "string", "enum": ["x", "bluesky", "linkedin"] },
        "minItems": 1,
        "uniqueItems": true,
        "description": "Where the post goes. Always pass it explicitly."
      },
      "scheduled_date": {
        "type": "string",
        "format": "date-time",
        "description": "Optional ISO 8601 time in the future, e.g. 2026-09-22T14:00:00Z."
      },
      "publish_now": {
        "type": "boolean",
        "description": "Publish immediately. Leave false to keep a human review step."
      }
    },
    "required": ["text", "platforms"],
    "additionalProperties": false
  }
}

The Python versions share one small helper that sends the request and turns the response into a line the agent can read:

opentweet.py
import os, requests

OPENTWEET = "https://opentweet.io/api/v1/posts"

def send_post(text: str, platforms: list[str],
              scheduled_date: str | None = None,
              publish_now: bool = False) -> str:
    payload: dict = {"text": text, "platforms": platforms}
    if scheduled_date:
        payload["scheduled_date"] = scheduled_date
    elif publish_now:
        payload["publish_now"] = True
    r = requests.post(
        OPENTWEET,
        headers={"Authorization": f"Bearer {os.environ['OPENTWEET_API_KEY']}"},
        json=payload,
        timeout=30,
    )
    if r.status_code >= 400:
        return f"Error {r.status_code}: {r.text[:300]}"
    post = r.json()["posts"][0]
    results = post.get("results") or []
    if not results:
        return f"Saved as {post.get('status', 'draft')} (id {post['id']})."
    return "; ".join(
        f"{x['platform']}: {x['status']} "
        f"{x.get('url') or x.get('skip_reason') or x.get('error') or ''}".strip()
        for x in results
    )

Drafts come back with an id. Published posts come back with a results entry per network. The full field list is in the cross-posting reference.

3. Register it in your framework

The OpenTweet half is identical in all four. Only the wrapper changes.

OpenAI Agents SDK

The @function_tool decorator builds the JSON schema from the type hints, so list[Literal[...]] becomes the platforms enum. The Runner calls the tool when the agent decides to post.

openai_agents_social_tool.py
from typing import Literal
from agents import Agent, Runner, function_tool
from opentweet import send_post

@function_tool
def post_to_social(text: str,
                   platforms: list[Literal["x", "bluesky", "linkedin"]],
                   scheduled_date: str | None = None,
                   publish_now: bool = False) -> str:
    """Create, schedule or publish one post on X, Bluesky and/or LinkedIn.

    Args:
        text: The post body. Bluesky allows 300 characters, LinkedIn 3,000.
        platforms: Where the post goes. Always pass it explicitly.
        scheduled_date: Optional ISO 8601 time in the future.
        publish_now: Publish immediately. If both are omitted the post is
                     saved as a draft for human review.
    """
    return send_post(text, platforms, scheduled_date, publish_now)

agent = Agent(
    name="Social agent",
    instructions="You write posts for X, Bluesky and LinkedIn. Save drafts unless told to publish.",
    tools=[post_to_social],
)

Runner.run_sync(agent, "Draft a LinkedIn and Bluesky post announcing our v2 launch.")

LangChain and LangGraph

Define the tool with @tool, then hand it to a LangGraph ReAct agent. The docstring is what the model reads to decide when and how to call it.

langchain_social_tool.py
from typing import Literal
from langchain_core.tools import tool
from opentweet import send_post

@tool
def post_to_social(text: str,
                   platforms: list[Literal["x", "bluesky", "linkedin"]],
                   scheduled_date: str | None = None,
                   publish_now: bool = False) -> str:
    """Create, schedule or publish one post on X, Bluesky and/or LinkedIn.

    Args:
        text: The post body. Bluesky allows 300 characters, LinkedIn 3,000.
        platforms: Where the post goes. Always pass it explicitly.
        scheduled_date: Optional ISO 8601 time in the future.
        publish_now: Publish immediately. If both are omitted the post is
                     saved as a draft for human review.
    """
    return send_post(text, platforms, scheduled_date, publish_now)

Then wire it into an agent:

langgraph_social_agent.py
from langchain.chat_models import init_chat_model
from langgraph.prebuilt import create_react_agent

llm = init_chat_model("anthropic:claude-opus-4-6")  # any LangChain model
agent = create_react_agent(llm, tools=[post_to_social])

agent.invoke({"messages": [
    {"role": "user", "content": "Schedule our changelog post on LinkedIn and Bluesky for Monday 9am UTC"}
]})

CrewAI

Wrap the call in a CrewAI @tool and give it to a Social Media Manager agent. This version only saves drafts, which is the safe default for a crew that runs unattended.

crewai_social_tool.py
from typing import Literal
from crewai.tools import tool
from opentweet import send_post

@tool("Post to social")
def post_to_social(text: str,
                   platforms: list[Literal["x", "bluesky", "linkedin"]]) -> str:
    """Save a post as a draft on X, Bluesky and/or LinkedIn through OpenTweet
    for human review. platforms is a list drawn from x, bluesky and linkedin."""
    return send_post(text, platforms)

Then wire it into an agent:

social_crew.py
from crewai import Agent, Task, Crew

social = Agent(
    role="Social Media Manager",
    goal="Turn product updates into posts for X, Bluesky and LinkedIn",
    backstory="You write a short version for X and Bluesky and a longer one for LinkedIn.",
    tools=[post_to_social],
)

Crew(agents=[social], tasks=[
    Task(description="Draft a LinkedIn post and a Bluesky post about our new API.",
         agent=social, expected_output="The draft ids OpenTweet returned")
]).kickoff()

Vercel AI SDK

Pass the same JSON schema straight in with jsonSchema(), so the TypeScript tool and the Python tools describe exactly the same input.

post-to-social.ts
import { generateText, tool, jsonSchema } from 'ai';

type PostInput = {
  text: string;
  platforms: ('x' | 'bluesky' | 'linkedin')[];
  scheduled_date?: string;
  publish_now?: boolean;
};

export const postToSocial = tool({
  description:
    'Create, schedule or publish one post on X, Bluesky and/or LinkedIn. ' +
    'With neither scheduled_date nor publish_now it is saved as a draft for human review.',
  inputSchema: jsonSchema<PostInput>({
    type: 'object',
    properties: {
      text: { type: 'string' },
      platforms: {
        type: 'array',
        items: { type: 'string', enum: ['x', 'bluesky', 'linkedin'] },
        minItems: 1,
      },
      scheduled_date: { type: 'string', format: 'date-time' },
      publish_now: { type: 'boolean' },
    },
    required: ['text', 'platforms'],
    additionalProperties: false,
  }),
  execute: async (input) => {
    const res = await fetch('https://opentweet.io/api/v1/posts', {
      method: 'POST',
      headers: {
        Authorization: 'Bearer ' + process.env.OPENTWEET_API_KEY,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(input),
    });
    const data = await res.json();
    if (!res.ok) return { error: data.error, details: data.details, results: data.results };
    const post = data.posts[0];
    return { id: post.id, status: post.status, results: post.results ?? [] };
  },
});

const { text } = await generateText({
  model: 'anthropic/claude-haiku-4.5',
  tools: { post_to_social: postToSocial },
  prompt: 'Draft a LinkedIn post about our v2 launch. Do not publish.',
});

4. Read the results per network

A published post carries one result per network. A post over a network's limit is skipped there with a reason and still goes out elsewhere, so check each entry rather than treating the call as all or nothing:

response excerpt
"results": [
  { "platform": "x", "status": "published", "url": "https://x.com/you/status/1834000000000000001" },
  { "platform": "bluesky", "status": "skipped", "skip_reason": "This post is 412 characters, over the 300 Bluesky allows." },
  { "platform": "linkedin", "status": "published", "url": "https://www.linkedin.com/feed/update/urn:li:share:7300000000000000000/" }
]

Returning this to the model lets it rewrite a skipped Bluesky version under 300 characters and post it on its own.

The MCP alternative

If the agent runs in an MCP client (Claude, Cursor, Windsurf, Cline, OpenClaw, or a framework with an MCP adapter), you do not write a tool at all. Point it at the hosted server at https://mcp.opentweet.io/mcp. It exposes 43 tools, and opentweet_create_tweet and opentweet_create_thread already take a platforms parameter.

mcp config
{
  "mcpServers": {
    "opentweet": {
      "type": "streamable-http",
      "url": "https://mcp.opentweet.io/mcp",
      "headers": { "Authorization": "Bearer ot_your_key" }
    }
  }
}
tool calls
opentweet_create_tweet({
  text: "We moved our job queue to Postgres. Three things we learned.",
  platforms: ["linkedin", "bluesky"]
})

// Not sure where a post without platforms would land? Ask first.
opentweet_list_platforms({})

Setup per network: LinkedIn MCP server and Bluesky MCP server. The npm package is @opentweet/mcp-server if you prefer to run it locally.

Caveats before you let it run

As of September 2026.

LinkedIn means your personal profile

OpenTweet posts to the connected member profile through the official LinkedIn API (Share on LinkedIn, w_member_social). Company Pages are not supported.

LinkedIn needs a reconnect every 60 days

LinkedIn gives self-serve apps no refresh token, so the sign-in expires after 60 days. Scheduled LinkedIn posts are held and you reconnect with one click.

Hashtags and mentions are plain text on LinkedIn

They publish as text, not links. URLs in the text work. A thread sent to LinkedIn becomes one post.

Always pass platforms

A post with no platforms value follows the account auto cross-post setting, which can mean every connected network. An explicit array is honoured exactly.

Keep a human review step

Leave publish_now false until you trust the output. Drafts land in the OpenTweet dashboard for you to approve, edit or delete.

No scraping, no browser session

Every network is reached through its official API and OAuth. There are no DMs or connection requests on LinkedIn, and no automation of a logged-in browser.

What this tool will not do

Instagram, TikTok, Facebook, YouTube and Threads are not supported. On LinkedIn there are no Company Page posts, no DMs and no connection requests.

Frequently asked questions

Can an AI agent post to LinkedIn?

Yes, to your personal profile. Give the agent a tool that calls POST https://opentweet.io/api/v1/posts with platforms set to ["linkedin"]. OpenTweet publishes through the official LinkedIn API after you sign in with LinkedIn once. Company Pages are not supported, and the sign-in has to be renewed every 60 days.

How do I give a CrewAI agent a LinkedIn tool?

Decorate a function with @tool from crewai.tools, type the platforms argument as list[Literal["x", "bluesky", "linkedin"]], and have it POST the text and platforms to /api/v1/posts with your ot_ key as a Bearer token. Add the tool to the agent's tools list. The CrewAI example on this page saves drafts only.

How do I build a LangChain or LangGraph social media agent?

Define post_to_social with LangChain's @tool decorator and pass it to create_react_agent from langgraph.prebuilt. The model picks the platforms from the enum in the tool schema, and one call can target X, Bluesky and LinkedIn together.

Do I need a LinkedIn developer app or a Bluesky App Password?

No. You connect LinkedIn with LinkedIn sign-in and Bluesky over atproto OAuth inside OpenTweet. Your agent only holds an OpenTweet ot_ key, which you can rotate or revoke at any time.

What happens if a post is too long for one network?

That network is skipped with a stated reason and the others still publish. Bluesky allows 300 characters and LinkedIn 3,000. The per-network results array tells your agent which one was skipped and why, so it can rewrite and retry.

Should I use MCP or a function tool?

Use MCP if your agent runs in an MCP client such as Claude, Cursor or an MCP-aware framework: point it at https://mcp.opentweet.io/mcp and it gets the posting tools with a platforms parameter already defined. Use a function tool when you build the agent loop yourself. Both reach the same backend.

Does this post to Instagram, TikTok or Facebook?

No. OpenTweet posts to X, Bluesky and LinkedIn only. Instagram, TikTok, Facebook, YouTube and Threads are not supported, so the platforms enum has exactly three values.

One tool, three networks

One ot_ key and one HTTP call post to X, Bluesky and LinkedIn. Flat $11.99 a month, no per-post fee.