Agentic AI Blog Distributor

Build an AI agent that publishes blog posts to X, Medium, and LinkedIn.

Introduction

30 Second Summary

Writing a blog post is only half the battle. Sharing it across X, Medium, and LinkedIn means rewriting the same content three different ways, logging into three platforms, and formatting everything by hand.

In this project, you will build an AI agent powered by Claude that reads a blog post and automatically publishes adapted versions to X, Medium, and LinkedIn.

What You'll Build

A Python-based AI agent that uses Claude's tool use API to read a Markdown blog post, adapt its content for three platforms, and publish it autonomously.

By the end of this project, you'll have:

  • A working agentic loop that lets Claude decide which tools to call, execute them, and repeat until the task is complete.
  • Three platform posting tools for X, Medium, and LinkedIn that Claude calls autonomously.
  • A system prompt that guides Claude's content adaptation strategy for each platform's audience and format.
  • Secret Mission: Add a dry-run mode and scheduling system to preview and queue posts before publishing.

Are there any prerequisites?

You should have basic Python knowledge and familiarity with REST APIs. You will also need a paid X Developer account (minimum $5 credit purchase) and a Medium account.

Set Up Your Development Environment

Before your AI agent can distribute blog posts anywhere, it needs a home. In this step you'll set up the Python environment, install the libraries your agent depends on, and get your Anthropic API key.

By the end of this step you'll have a project folder with all dependencies installed and an API key ready for Claude to use.

In this step, get ready to:
  • Install Python and create a virtual environment with all project dependencies.
  • Create an Anthropic account and generate an API key.
  • Set up your project files including a .env file for all credentials.
Install Python and set up your project

This project requires Python version 3.9 or higher. Let's confirm it's installed on your machine.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Check your Python version by running this command:

macOS

python3 --version

Windows

python --version

✔️ I see version 3.9 or higher

You're all set. Move on to creating your project folder below.

ⓧ I see an older version

Your system has an older Python that won't work with the libraries this project uses. Download and install Python 3.9 or newer from the official site:

https://www.python.org/downloads

On macOS, the system may still point python3 to the older version after installing. If so, use the full path to the new binary (e.g. /usr/local/bin/python3.12) or install via a version manager like pyenv.

After installing, re-run the version check to confirm you see 3.9 or higher.

ⓧ Command not found

Python isn't installed on your machine yet. Download and install it from the official site:

https://www.python.org/downloads

  • Download the latest Python 3 installer for your operating system.
  • Run the installer. On Windows, check the box that says Add Python to PATH before clicking Install Now.
  • Close and reopen your terminal, then re-run the version check.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir blog-distributor
cd blog-distributor
  • Confirm the folder exists by running:
ls

You should see an empty listing (the folder was just created). Now create a virtual environment and activate it.

  • Create a virtual environment by running:
python3 -m venv venv
  • Activate the virtual environment by running:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

What is a virtual environment?

A virtual environment is an isolated Python workspace. Packages you install here won't affect your system Python or other projects.

This keeps your blog distributor's dependencies separate and prevents version conflicts.

Now you'll create the requirements.txt file that lists the packages your agent needs.

  • Create a file called requirements.txt in your blog-distributor folder and add the following content:
anthropic>=0.102.0
requests>=2.31.0
python-dotenv>=1.0.0
requests-oauthlib>=1.3.1

What do these packages do?

  • anthropic is the official Python SDK for talking to Claude's API.
  • requests handles HTTP calls to the X, Medium, and LinkedIn APIs.
  • python-dotenv loads your API keys from the .env file so they stay out of your code.
  • requests-oauthlib adds OAuth 1.0a support, which X requires for posting tweets.
  • Install all four packages by running:
pip install -r requirements.txt

You should see output ending with Successfully installed followed by the package names and versions.

Seeing permission errors or install failures?

  • Make sure you see (venv) in your terminal prompt. If not, re-run the activation command above.
  • On Windows, make sure you're using pip (not pip3) when the virtual environment is active.
  • If you see a network error, check your internet connection and try again.

Why won't my packages install?

Create an Anthropic account and generate an API key

Your agent uses Claude's tool use feature to decide how to adapt and post your blog content. To access Claude, you need an API key from Anthropic.

  • Open console.anthropic.com in your browser.
  • Click Sign up and create an account using your email or Google login.
  • Complete email and phone verification when prompted.
  • Once logged in, navigate to Settings then API Keys.
  • Click Create Key.
  • Give your key a name like blog-distributor and copy the key value.

Save your API key now

The full API key is only shown once at creation. Copy it immediately and paste it somewhere safe (a temporary text file works). You'll add it to your .env file in the next substep.

How much does the Claude API cost?

This project uses claude-sonnet-4-5, which costs $3 per million input tokens and $15 per million output tokens. For a single run of your agent, expect to spend around $0.05 to $0.10.

Set up your environment file

A .env file stores your API keys outside your code. This keeps secrets out of version control and makes them easy to update.

  • Create a file called .env in your blog-distributor folder and add the following content:
ANTHROPIC_API_KEY=your-api-key-here
X_API_KEY=your-x-api-key-here
X_API_SECRET=your-x-api-secret-here
X_ACCESS_TOKEN=your-x-access-token-here
X_ACCESS_TOKEN_SECRET=your-x-access-token-secret-here
MEDIUM_TOKEN=your-medium-integration-token-here
LINKEDIN_ACCESS_TOKEN=your-linkedin-access-token-here
LINKEDIN_PERSON_URN=urn:li:person:your-person-id-here
  • Replace your-api-key-here on the ANTHROPIC_API_KEY line with the API key you just copied from Anthropic.

What about the other keys?

Leave the X, Medium, and LinkedIn placeholder values for now. You'll replace them with real credentials in the next step when you set up each platform.

  • Save both requirements.txt and .env.

Now open your project in VS Code so you can work with files more easily.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the blog-distributor folder, then click Open.

In the Explorer sidebar on the left, you should see requirements.txt and .env listed.

Don't see the .env file?

Files starting with a dot are hidden by default on some systems. In VS Code's Explorer they should still appear. If you created the file via Finder or File Explorer, make sure hidden files are visible.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

Here's what each file should look like:

anthropic>=0.102.0
requests>=2.31.0
python-dotenv>=1.0.0
requests-oauthlib>=1.3.1
ANTHROPIC_API_KEY=your-api-key-here
X_API_KEY=your-x-api-key-here
X_API_SECRET=your-x-api-secret-here
X_ACCESS_TOKEN=your-x-access-token-here
X_ACCESS_TOKEN_SECRET=your-x-access-token-secret-here
MEDIUM_TOKEN=your-medium-integration-token-here
LINKEDIN_ACCESS_TOKEN=your-linkedin-access-token-here
LINKEDIN_PERSON_URN=urn:li:person:your-person-id-here

The ANTHROPIC_API_KEY line should have your real key instead of the placeholder.

Your development environment is ready and your Anthropic API key is in place. Next up, you'll get credentials for X, Medium, and LinkedIn so your agent can actually post to those platforms.

Get Your Platform API Credentials

Your agent needs to talk to three external platforms: X, Medium, and LinkedIn. Each platform requires its own authentication credentials so that your Python code can post on your behalf.

In this step, you will create developer accounts, generate API keys, and add all of these credentials to your .env file.

In this step, get ready to:
  • Set up an X Developer account and get your OAuth 1.0a keys.
  • Generate a Medium Integration Token.
  • (Optional) Create a LinkedIn Developer App and request API access.
Create an X Developer account and get your API keys

The X API uses OAuth 1.0a for personal bot authentication. You need four credentials: an API Key, an API Secret, an Access Token, and an Access Token Secret.

X API requires a paid credit purchase

The X API uses pay-per-usage pricing with no free tier for new developers. You must purchase a minimum of $5 in API credits to enable posting. Each post costs $0.01, so your $5 will cover hundreds of posts.

  • Open your browser and go to console.x.com.
  • Sign in with your X (Twitter) account.
  • Accept the Developer Agreement and Policy when prompted.
  • Provide basic information about how you will use the API (select personal/hobby use).
  • Once on the Developer Console dashboard, click New App.
  • Enter an app name (e.g. blog-distributor) and a short description.
  • After your app is created, the console displays your API Key, API Secret, and a Bearer Token. Copy the API Key and API Secret immediately.

Credentials are shown only once

The X Developer Console only displays your credentials once when they are generated. Save them immediately in a safe place. If you lose them, you will need to regenerate them, which invalidates the old ones.

  • You also need an Access Token and Access Token Secret for your own account. In the Developer Console, navigate to your app and find the Keys and Tokens section.
  • Generate your Access Token and Secret. These allow your app to post on behalf of your own account.
  • Purchase API credits by navigating to the billing section in the Developer Console. The minimum purchase is $5.

Why do I need four separate keys?

OAuth 1.0a uses two pairs of credentials. The API Key and API Secret identify your app. The Access Token and Access Token Secret identify which user account the app is acting on behalf of. Together, they sign every request.

  • Open your .env file in VS Code.
  • Replace the placeholder values for X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, and X_ACCESS_TOKEN_SECRET with the real values you just copied.
  • Save .env.

Can't find your Access Token?

If you only see the API Key and Bearer Token after creating your app, navigate to the Keys and Tokens tab for your app in the Developer Console. The Access Token and Access Token Secret are generated separately from that page.

help me find my X Access Token and Secret in the Developer Console

Generate a Medium Integration Token

Medium uses a self-issued Integration Token for API authentication. This is simpler than OAuth because you generate the token directly from your account settings.

What is an Integration Token?

An Integration Token is a long-lived access credential that lets your code publish articles to your Medium account. It does not expire unless you revoke it manually.

  • Log in to your Medium account at medium.com.
  • Click your profile icon in the top right corner.
  • Click Settings.
  • Scroll down to find the Security and apps section, then look for Integration tokens.
  • Enter a description for your token (e.g. blog-distributor) and click to generate it.
  • Copy the generated token immediately.

Medium API deprecation notice

Medium's official API docs state that the API is no longer supported and no new integration tokens are being issued for new integrations. However, if you already have a Medium account, existing tokens continue to work for creating posts. If you cannot find the Integration Tokens section in your settings, you can skip Medium and the agent will simply report the failure for that platform.

  • In your .env file, replace the placeholder value for MEDIUM_TOKEN with the token you just copied.
  • Save .env.

Can't find Integration Tokens in your settings?

Medium has moved this section around over time. Try looking under Settings > Security and apps > Integration tokens. If it still does not appear, Medium may have disabled token generation for your account. You can still complete this project with X alone.

help me find the Medium Integration Token in my account settings

(Optional) Create a LinkedIn Developer App

LinkedIn's API requires a formal developer app approval process that can take days to weeks. This step is optional because you can complete the project with X and Medium while waiting for LinkedIn approval.

LinkedIn API access requires business verification

The LinkedIn Community Management API is only available to registered legal organizations for commercial use cases. Individual developers may find their applications rejected. If you do not get approved, the agent handles this gracefully by skipping LinkedIn posts.

  • Go to developer.linkedin.com and sign in with your LinkedIn account.
  • Create a new developer application with a name and description.
  • Navigate to the Products tab under your app name.
  • Find the Community Management API in the list and click to request access. This grants the w_member_social permission needed for posting.
  • Complete the access request form that LinkedIn provides.
  • Once approved, generate an access token using the Token Generator tool in the Developer Portal.
  • Find your Person URN (it looks like urn:li:person:AbC123) from the Auth tab or by calling the /v2/userinfo endpoint.
  • If you received approval, open your .env file and replace the placeholder values for LINKEDIN_ACCESS_TOKEN and LINKEDIN_PERSON_URN.
  • Save .env.

What if I skip LinkedIn?

The agent is designed to handle missing credentials gracefully. If LINKEDIN_ACCESS_TOKEN is left as the placeholder value, the LinkedIn posting function returns an error message, and Claude moves on to the other platforms. You can always add LinkedIn credentials later.

LinkedIn tokens expire in 60 days

Unlike X and Medium tokens, LinkedIn access tokens expire after approximately 60 days. If you come back to this project later and LinkedIn posting fails, you may need to regenerate your access token in the Developer Portal.

Your .env file should now have real credentials for at least X and Medium (and optionally LinkedIn). Here is what the structure looks like with placeholder values still in place for any platforms you skipped:

ANTHROPIC_API_KEY=your-api-key-here
X_API_KEY=your-x-api-key-here
X_API_SECRET=your-x-api-secret-here
X_ACCESS_TOKEN=your-x-access-token-here
X_ACCESS_TOKEN_SECRET=your-x-access-token-secret-here
MEDIUM_TOKEN=your-medium-integration-token-here
LINKEDIN_ACCESS_TOKEN=your-linkedin-access-token-here
LINKEDIN_PERSON_URN=urn:li:person:your-person-id-here

What does this file do?

The .env file stores all your API credentials outside of your code. The python-dotenv library reads these values at runtime so your secrets never appear in source files.

Never commit this file to version control. Add .env to your .gitignore if you plan to use Git.

✔️ Awesome, I've got everything!

Great. Double check you have saved your .env file with real credentials for at least X (all four keys) and Medium (one token).

ⓧ I'd like to double check the full code

Here is what your complete .env file should look like (with your real values substituted for the placeholders):

ANTHROPIC_API_KEY=your-api-key-here
X_API_KEY=your-x-api-key-here
X_API_SECRET=your-x-api-secret-here
X_ACCESS_TOKEN=your-x-access-token-here
X_ACCESS_TOKEN_SECRET=your-x-access-token-secret-here
MEDIUM_TOKEN=your-medium-integration-token-here
LINKEDIN_ACCESS_TOKEN=your-linkedin-access-token-here
LINKEDIN_PERSON_URN=urn:li:person:your-person-id-here

Your credentials are locked and loaded. Next up, you will write the Python functions that use these credentials to actually post content to each platform.

Build the Platform Posting Tools

Your environment is ready and your API credentials are locked in. Now it's time to build the functions that actually talk to each platform.

Each posting tool is a standalone Python function that takes content as input, calls the platform's API, and returns a structured JSON result. This pattern lets Claude's agent loop call them like any other tool and understand what happened.

In this step, get ready to:
  • Create a tools file with a blog reader and an X posting function.
  • Build a Medium posting function that publishes articles as drafts.
  • Build a LinkedIn posting function and wire all tools into a dispatch map.
Create the tools file and X posting function

Every tool your agent will use lives in a single file called tools.py. You'll start by setting up the file with imports, a blog reader utility, and the X posting function.

  • Create a new file called tools.py in your project folder.
  • Add the imports, environment loader, and blog reader function by pasting this code:
import os
import json
import requests
from requests_oauthlib import OAuth1
from dotenv import load_dotenv

load_dotenv()


def read_blog_post(file_path: str) -> str:
    """Read a blog post from a Markdown file."""
    try:
        with open(file_path, "r", encoding="utf-8") as f:
            content = f.read()
        return json.dumps({"success": True, "content": content})
    except FileNotFoundError:
        return json.dumps({"success": False, "error": f"File not found: {file_path}"})

What does this code do?

The imports bring in the libraries you installed earlier. load_dotenv() loads your .env credentials into environment variables so every function can access them.

The read_blog_post() function opens a Markdown file and returns its content as a JSON string. Every tool in this file returns JSON so that Claude can parse the result and decide what to do next.

Now for the first platform function. X uses OAuth 1.0a for authentication, which means you sign each request with four separate credentials. The requests-oauthlib library handles the signing for you.

  • Add the post_to_x() function below read_blog_post() by pasting this code at the bottom of tools.py:
def post_to_x(text: str) -> str:
    """Post a tweet to X (Twitter) using OAuth 1.0a."""
    auth = OAuth1(
        os.getenv("X_API_KEY"),
        os.getenv("X_API_SECRET"),
        os.getenv("X_ACCESS_TOKEN"),
        os.getenv("X_ACCESS_TOKEN_SECRET"),
    )

    response = requests.post(
        "https://api.x.com/2/tweets",
        json={"text": text},
        auth=auth,
    )

    if response.status_code == 201:
        data = response.json()
        return json.dumps({
            "success": True,
            "tweet_id": data["data"]["id"],
            "message": "Tweet posted successfully",
        })
    else:
        return json.dumps({
            "success": False,
            "error": f"HTTP {response.status_code}: {response.text}",
        })

What does this code do?

  • OAuth1() creates an authentication object using your four X credentials from the .env file.
  • The function sends a POST request to https://api.x.com/2/tweets with the tweet text as JSON.
  • A 201 status code means success. Anything else gets returned as an error with the full HTTP response for debugging.
  • Save tools.py.
  • Confirm the file exists by running:
python -c "from tools import post_to_x; print('X tool loaded successfully')"

You should see X tool loaded successfully printed in your terminal.

Seeing an import error?

  • Make sure your virtual environment is activated. You should see (venv) in your terminal prompt.
  • Check that you installed all dependencies with pip install -r requirements.txt in the previous step.

Help me debug this import error in tools.py

Create the Medium posting function

The Medium API requires a two-step process: first you fetch your user ID, then you use that ID to create a post. The function handles both requests in sequence.

  • Add the post_to_medium() function at the bottom of tools.py:
def post_to_medium(title: str, content: str, tags: list = None) -> str:
    """Publish an article to Medium using the Integration Token."""
    token = os.getenv("MEDIUM_TOKEN")

    # First, get the user ID
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
        "Accept": "application/json",
    }

    user_response = requests.get(
        "https://api.medium.com/v1/me",
        headers=headers,
    )

    if user_response.status_code != 200:
        return json.dumps({
            "success": False,
            "error": f"Failed to get user info: {user_response.text}",
        })

    user_id = user_response.json()["data"]["id"]

    # Create the post
    post_data = {
        "title": title,
        "contentFormat": "markdown",
        "content": content,
        "publishStatus": "draft",
    }

    if tags:
        post_data["tags"] = tags[:5]

    post_response = requests.post(
        f"https://api.medium.com/v1/users/{user_id}/posts",
        headers=headers,
        json=post_data,
    )

    if post_response.status_code == 201:
        data = post_response.json()
        return json.dumps({
            "success": True,
            "post_url": data["data"]["url"],
            "message": "Medium article published as draft",
        })
    else:
        return json.dumps({
            "success": False,
            "error": f"HTTP {post_response.status_code}: {post_response.text}",
        })

What does this code do?

  • First, it calls GET https://api.medium.com/v1/me to look up your Medium user ID using the Integration Token.
  • Then it sends the blog content as a markdown-formatted post to POST https://api.medium.com/v1/users/{userId}/posts.
  • Posts are created as drafts (publishStatus: "draft") so you can review them on Medium before publishing live.
  • Tags are capped at 5 (Medium's maximum) using tags[:5].
  • Save tools.py.

You won't see a change yet. The next function completes the set, and then you'll verify all tools load together.

Create the LinkedIn posting function and dispatch map

The LinkedIn API uses OAuth 2.0 and requires a versioned header. This function also includes a graceful fallback: if LinkedIn credentials aren't configured, it returns an error message instead of crashing.

  • Add the post_to_linkedin() function at the bottom of tools.py:
def post_to_linkedin(text: str) -> str:
    """Post to LinkedIn using the Community Management API."""
    access_token = os.getenv("LINKEDIN_ACCESS_TOKEN")
    person_urn = os.getenv("LINKEDIN_PERSON_URN")

    if not access_token or not person_urn:
        return json.dumps({
            "success": False,
            "error": "LinkedIn credentials not configured. Skipping LinkedIn post.",
        })

    headers = {
        "Authorization": f"Bearer {access_token}",
        "Content-Type": "application/json",
        "LinkedIn-Version": "202605",
        "X-Restli-Protocol-Version": "2.0.0",
    }

    post_data = {
        "author": person_urn,
        "commentary": text,
        "visibility": "PUBLIC",
        "distribution": {
            "feedDistribution": "MAIN_FEED",
            "targetEntities": [],
            "thirdPartyDistributionChannels": [],
        },
        "lifecycleState": "PUBLISHED",
        "isReshareDisabledByAuthor": False,
    }

    response = requests.post(
        "https://api.linkedin.com/rest/posts",
        headers=headers,
        json=post_data,
    )

    if response.status_code == 201:
        post_id = response.headers.get("x-restli-id", "unknown")
        return json.dumps({
            "success": True,
            "post_id": post_id,
            "message": "LinkedIn post published successfully",
        })
    else:
        return json.dumps({
            "success": False,
            "error": f"HTTP {response.status_code}: {response.text}",
        })

What does this code do?

  • The credential check at the top means this function won't crash if you skipped LinkedIn setup. It returns a clear error message that Claude can read and report.
  • LinkedIn-Version: "202605" and X-Restli-Protocol-Version: "2.0.0" are required headers for the LinkedIn Community Management API.
  • The post payload uses your person_urn as the author and publishes directly to the main feed.

Finally, you need a way for the agent to look up which function to call by name. A dispatch map connects tool name strings to their Python functions.

  • Add the dispatch map at the very bottom of tools.py:
# Tool dispatch map
TOOL_FUNCTIONS = {
    "read_blog_post": read_blog_post,
    "post_to_x": post_to_x,
    "post_to_medium": post_to_medium,
    "post_to_linkedin": post_to_linkedin,
}

Why a dispatch map?

When Claude tells the agent to call post_to_x, the agent receives that as a string. The TOOL_FUNCTIONS dictionary maps those strings to the actual Python functions so the agent can execute them with a simple dictionary lookup.

  • Save tools.py.
  • Verify all tools load correctly by running:
python -c "from tools import TOOL_FUNCTIONS; print(f'Loaded {len(TOOL_FUNCTIONS)} tools:', list(TOOL_FUNCTIONS.keys()))"

You should see all four tool names printed: read_blog_post, post_to_x, post_to_medium, and post_to_linkedin.

Seeing a NameError or SyntaxError?

  • A NameError on a function name means the dispatch map references a function that wasn't defined above it. Check that all four functions appear before TOOL_FUNCTIONS.
  • A SyntaxError usually means a missing comma, colon, or mismatched bracket. Check the line number in the error message.
  • Make sure TOOL_FUNCTIONS is at the top level of the file (no indentation), not nested inside a function.

Help me fix this error in my tools.py file

✔️ Awesome, I've got everything!

Great. Double check you've saved tools.py and that all four tools loaded without errors.

ⓧ I'd like to double check the full code

Here's the complete tools.py file. Compare it to yours and fix any differences.

import os
import json
import requests
from requests_oauthlib import OAuth1
from dotenv import load_dotenv

load_dotenv()


def read_blog_post(file_path: str) -> str:
    """Read a blog post from a Markdown file."""
    try:
        with open(file_path, "r", encoding="utf-8") as f:
            content = f.read()
        return json.dumps({"success": True, "content": content})
    except FileNotFoundError:
        return json.dumps({"success": False, "error": f"File not found: {file_path}"})


def post_to_x(text: str) -> str:
    """Post a tweet to X (Twitter) using OAuth 1.0a."""
    auth = OAuth1(
        os.getenv("X_API_KEY"),
        os.getenv("X_API_SECRET"),
        os.getenv("X_ACCESS_TOKEN"),
        os.getenv("X_ACCESS_TOKEN_SECRET"),
    )

    response = requests.post(
        "https://api.x.com/2/tweets",
        json={"text": text},
        auth=auth,
    )

    if response.status_code == 201:
        data = response.json()
        return json.dumps({
            "success": True,
            "tweet_id": data["data"]["id"],
            "message": "Tweet posted successfully",
        })
    else:
        return json.dumps({
            "success": False,
            "error": f"HTTP {response.status_code}: {response.text}",
        })


def post_to_medium(title: str, content: str, tags: list = None) -> str:
    """Publish an article to Medium using the Integration Token."""
    token = os.getenv("MEDIUM_TOKEN")

    # First, get the user ID
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
        "Accept": "application/json",
    }

    user_response = requests.get(
        "https://api.medium.com/v1/me",
        headers=headers,
    )

    if user_response.status_code != 200:
        return json.dumps({
            "success": False,
            "error": f"Failed to get user info: {user_response.text}",
        })

    user_id = user_response.json()["data"]["id"]

    # Create the post
    post_data = {
        "title": title,
        "contentFormat": "markdown",
        "content": content,
        "publishStatus": "draft",
    }

    if tags:
        post_data["tags"] = tags[:5]

    post_response = requests.post(
        f"https://api.medium.com/v1/users/{user_id}/posts",
        headers=headers,
        json=post_data,
    )

    if post_response.status_code == 201:
        data = post_response.json()
        return json.dumps({
            "success": True,
            "post_url": data["data"]["url"],
            "message": "Medium article published as draft",
        })
    else:
        return json.dumps({
            "success": False,
            "error": f"HTTP {post_response.status_code}: {post_response.text}",
        })


def post_to_linkedin(text: str) -> str:
    """Post to LinkedIn using the Community Management API."""
    access_token = os.getenv("LINKEDIN_ACCESS_TOKEN")
    person_urn = os.getenv("LINKEDIN_PERSON_URN")

    if not access_token or not person_urn:
        return json.dumps({
            "success": False,
            "error": "LinkedIn credentials not configured. Skipping LinkedIn post.",
        })

    headers = {
        "Authorization": f"Bearer {access_token}",
        "Content-Type": "application/json",
        "LinkedIn-Version": "202605",
        "X-Restli-Protocol-Version": "2.0.0",
    }

    post_data = {
        "author": person_urn,
        "commentary": text,
        "visibility": "PUBLIC",
        "distribution": {
            "feedDistribution": "MAIN_FEED",
            "targetEntities": [],
            "thirdPartyDistributionChannels": [],
        },
        "lifecycleState": "PUBLISHED",
        "isReshareDisabledByAuthor": False,
    }

    response = requests.post(
        "https://api.linkedin.com/rest/posts",
        headers=headers,
        json=post_data,
    )

    if response.status_code == 201:
        post_id = response.headers.get("x-restli-id", "unknown")
        return json.dumps({
            "success": True,
            "post_id": post_id,
            "message": "LinkedIn post published successfully",
        })
    else:
        return json.dumps({
            "success": False,
            "error": f"HTTP {response.status_code}: {response.text}",
        })


# Tool dispatch map
TOOL_FUNCTIONS = {
    "read_blog_post": read_blog_post,
    "post_to_x": post_to_x,
    "post_to_medium": post_to_medium,
    "post_to_linkedin": post_to_linkedin,
}

Your platform tools are built and ready. Next up, you'll define the tool schemas that tell Claude what each function does and how to call it.

Define the Agent Tools and System Prompt

Your posting tools are built and ready to go. Now you need to give Claude a way to understand and use them.

An AI agent needs two things beyond its raw tools: a system prompt that defines its behavior, and JSON schemas that describe what each tool does so the model knows when and how to call them. In this step, you'll create agent.py with both of these pieces, plus a dispatcher function that routes Claude's tool calls to your actual Python functions.

In this step, get ready to:
  • Write a system prompt that guides Claude's content adaptation strategy.
  • Define JSON tool schemas so Claude knows each tool's inputs and purpose.
  • Create a dispatcher function that routes tool calls to the right functions.
Create agent.py and write the system prompt

The system prompt is the agent's instruction manual. It tells Claude what role it plays, what workflow to follow, and what rules to obey when adapting content for each platform.

  • Create a new file called agent.py in your project folder.
  • Add the following imports, client setup, and system prompt:
import sys
from anthropic import Anthropic
from dotenv import load_dotenv
from tools import TOOL_FUNCTIONS

load_dotenv()

# Initialize the Anthropic client (reads ANTHROPIC_API_KEY from .env)
client = Anthropic()

# System prompt defines the agent's behavior and content adaptation rules
SYSTEM_PROMPT = """You are a blog distribution agent. Your job is to take a blog post and publish adapted versions to multiple platforms.

Your workflow:
1. First, use read_blog_post to load the blog content
2. Then, adapt and post to each platform:
   - X (Twitter): Create a concise, engaging tweet (max 280 characters) that captures the key insight of the blog post. Include relevant hashtags.
   - Medium: Publish the full blog post as a draft article with an appropriate title and tags.
   - LinkedIn: Write a professional summary (1-3 paragraphs) highlighting the key takeaways, suitable for a professional audience.
3. Report the results of each posting attempt.

Important rules:
- Always read the blog post first before posting anywhere
- Adapt content appropriately for each platform's audience and format
- If a platform post fails, report the error and continue with other platforms
- Do not exceed character limits (280 for X tweets)
"""

What does this code do?

The imports bring in the Anthropic SDK, dotenv for loading your API key, and the TOOL_FUNCTIONS dispatch map you built in tools.py.

The SYSTEM_PROMPT string gives Claude a clear workflow: read the blog first, then adapt and post to each platform with specific format rules. This is what makes the agent autonomous. Claude will follow these instructions to decide which tools to call and in what order.

  • Save agent.py.

Seeing a red underline on the Anthropic import?

Make sure your virtual environment is activated. Your editor needs to use the Python interpreter inside the virtual environment to find installed packages.

Also confirm you ran pip install anthropic in Step 1.

Help me fix my Anthropic import error

Define the tool schemas for Claude

Claude cannot inspect your Python functions directly. Instead, you provide JSON schemas that describe each tool's name, purpose, and expected inputs. Claude reads these descriptions to decide which tool to call and what arguments to pass.

  • In agent.py, add the following TOOL_DEFINITIONS list below your SYSTEM_PROMPT:
# Tool schemas tell Claude what tools are available and how to call them
TOOL_DEFINITIONS = [
    {
        "name": "read_blog_post",
        "description": "Read a blog post from a Markdown file on disk. Use this first to get the blog content before adapting it for each platform.",
        "input_schema": {
            "type": "object",
            "properties": {
                "file_path": {
                    "type": "string",
                    "description": "Path to the Markdown file containing the blog post",
                }
            },
            "required": ["file_path"],
        },
    },
    {
        "name": "post_to_x",
        "description": "Post a tweet to X (Twitter). The text must be 280 characters or fewer.",
        "input_schema": {
            "type": "object",
            "properties": {
                "text": {
                    "type": "string",
                    "description": "The tweet text (max 280 characters)",
                }
            },
            "required": ["text"],
        },
    },
    {
        "name": "post_to_medium",
        "description": "Publish a full article to Medium as a draft. Content should be in Markdown format.",
        "input_schema": {
            "type": "object",
            "properties": {
                "title": {
                    "type": "string",
                    "description": "The article title",
                },
                "content": {
                    "type": "string",
                    "description": "The full article content in Markdown format",
                },
                "tags": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Up to 5 tags for the article",
                },
            },
            "required": ["title", "content"],
        },
    },
    {
        "name": "post_to_linkedin",
        "description": "Post a professional update to LinkedIn. Write 1-3 paragraphs suitable for a professional audience.",
        "input_schema": {
            "type": "object",
            "properties": {
                "text": {
                    "type": "string",
                    "description": "The LinkedIn post text (1-3 professional paragraphs)",
                }
            },
            "required": ["text"],
        },
    },
]

What do these schemas do?

Each tool definition has three parts: a name that matches the key in your TOOL_FUNCTIONS dictionary, a description that tells Claude when to use it, and an input_schema that defines what arguments Claude should pass.

Notice how the descriptions include usage guidance like "Use this first" for read_blog_post. Claude reads these descriptions to decide which tool to call next. Vague descriptions lead to wrong tool calls.

Why is the read_blog_post tool defined here and in tools.py?

The function in tools.py is the actual Python code that runs. The schema here in agent.py is a JSON description that tells Claude the tool exists and how to call it. Both are required for tool use to work.

  • Save agent.py.

Getting a syntax error on the list?

Check that every dictionary in the list has matching curly braces and that each item ends with a comma (except the last one before the closing bracket).

Help me debug my TOOL_DEFINITIONS syntax

Create the tool dispatcher function

When Claude decides to call a tool, it returns the tool name and input arguments. You need a function that takes those values and calls the correct Python function from your TOOL_FUNCTIONS map.

  • Add the execute_tool() function at the bottom of agent.py:
def execute_tool(tool_name: str, tool_input: dict) -> str:
    """Execute a tool and return the result as a string."""
    if tool_name in TOOL_FUNCTIONS:
        func = TOOL_FUNCTIONS[tool_name]
        return func(**tool_input)
    return f'{{"success": false, "error": "Unknown tool: {tool_name}"}}'

What does this function do?

This is the bridge between Claude and your tools. It looks up the tool name in the TOOL_FUNCTIONS dictionary (imported from tools.py), then calls the matching function with the arguments Claude provided.

If Claude tries to call a tool that does not exist, the function returns a JSON error string. This lets Claude know something went wrong without crashing your program.

  • Save agent.py.
  • In your terminal, verify the file has no syntax errors by running:
python agent.py

You should see no output and no errors. The script exits cleanly because there is no __main__ block yet.

Seeing a ModuleNotFoundError?

If the error mentions anthropic, make sure your virtual environment is activated before running the command.

If the error mentions tools, confirm that tools.py is in the same folder as agent.py.

Help me fix my ModuleNotFoundError

✔️ Awesome, I've got everything!

Great. Double check you have saved agent.py and that it ran without errors.

ⓧ I'd like to double check the full code

import sys
from anthropic import Anthropic
from dotenv import load_dotenv
from tools import TOOL_FUNCTIONS

load_dotenv()

client = Anthropic()

SYSTEM_PROMPT = """You are a blog distribution agent. Your job is to take a blog post and publish adapted versions to multiple platforms.

Your workflow:
1. First, use read_blog_post to load the blog content
2. Then, adapt and post to each platform:
   - X (Twitter): Create a concise, engaging tweet (max 280 characters) that captures the key insight of the blog post. Include relevant hashtags.
   - Medium: Publish the full blog post as a draft article with an appropriate title and tags.
   - LinkedIn: Write a professional summary (1-3 paragraphs) highlighting the key takeaways, suitable for a professional audience.
3. Report the results of each posting attempt.

Important rules:
- Always read the blog post first before posting anywhere
- Adapt content appropriately for each platform's audience and format
- If a platform post fails, report the error and continue with other platforms
- Do not exceed character limits (280 for X tweets)
"""

TOOL_DEFINITIONS = [
    {
        "name": "read_blog_post",
        "description": "Read a blog post from a Markdown file on disk. Use this first to get the blog content before adapting it for each platform.",
        "input_schema": {
            "type": "object",
            "properties": {
                "file_path": {
                    "type": "string",
                    "description": "Path to the Markdown file containing the blog post",
                }
            },
            "required": ["file_path"],
        },
    },
    {
        "name": "post_to_x",
        "description": "Post a tweet to X (Twitter). The text must be 280 characters or fewer.",
        "input_schema": {
            "type": "object",
            "properties": {
                "text": {
                    "type": "string",
                    "description": "The tweet text (max 280 characters)",
                }
            },
            "required": ["text"],
        },
    },
    {
        "name": "post_to_medium",
        "description": "Publish a full article to Medium as a draft. Content should be in Markdown format.",
        "input_schema": {
            "type": "object",
            "properties": {
                "title": {
                    "type": "string",
                    "description": "The article title",
                },
                "content": {
                    "type": "string",
                    "description": "The full article content in Markdown format",
                },
                "tags": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Up to 5 tags for the article",
                },
            },
            "required": ["title", "content"],
        },
    },
    {
        "name": "post_to_linkedin",
        "description": "Post a professional update to LinkedIn. Write 1-3 paragraphs suitable for a professional audience.",
        "input_schema": {
            "type": "object",
            "properties": {
                "text": {
                    "type": "string",
                    "description": "The LinkedIn post text (1-3 professional paragraphs)",
                }
            },
            "required": ["text"],
        },
    },
]


def execute_tool(tool_name: str, tool_input: dict) -> str:
    """Execute a tool and return the result as a string."""
    if tool_name in TOOL_FUNCTIONS:
        func = TOOL_FUNCTIONS[tool_name]
        return func(**tool_input)
    return f'{{"success": false, "error": "Unknown tool: {tool_name}"}}'

You now have everything Claude needs to understand its job and its tools. Next up, you'll build the agent loop that sends messages to Claude, handles tool calls, and repeats until the job is done.

Build the Agent Loop

You have your tools defined, your schemas written, and a dispatch function ready to go. Now you need the brain of the operation: a loop that gives Claude control over which tools to call and keeps running until the job is done.

This is the agentic loop pattern. You send a message to Claude with your available tools, check if it wants to call one, execute it, feed the result back, and repeat until Claude says it is finished.

In this step, get ready to:
  • Implement the core agentic loop that sends messages to Claude and processes tool calls.
  • Add error handling and a max iterations cap to prevent runaway loops.
  • Create a main entry point that accepts a blog file path from the command line.
Implement the agentic loop

The agentic loop is the core pattern behind every AI agent. Claude receives a task, decides which tool to call, you execute the tool and feed the result back, and Claude continues deciding until it has completed the job.

  • In agent.py, find the execute_tool function you wrote in the previous step.
  • Add the following code below it to create the run_agent function and the main entry point:
def run_agent(blog_file: str, max_steps: int = 10) -> str:
    """Run the blog distribution agent."""
    # Start the conversation with a user message asking Claude to distribute the blog
    messages = [
        {
            "role": "user",
            "content": f"Please read the blog post at '{blog_file}' and distribute it to X, Medium, and LinkedIn.",
        }
    ]

    print(f"\n{'='*60}")
    print(f"Blog Distribution Agent Starting")
    print(f"Blog file: {blog_file}")
    print(f"{'='*60}\n")

    # The agentic loop: keep going until Claude says it is done or we hit max steps
    for step in range(max_steps):
        print(f"--- Step {step + 1} ---")

        # Send the conversation to Claude with our tools
        response = client.messages.create(
            model="claude-sonnet-4-5",
            max_tokens=4096,
            system=SYSTEM_PROMPT,
            tools=TOOL_DEFINITIONS,
            messages=messages,
        )

        # Add Claude's response to the conversation history
        messages.append({"role": "assistant", "content": response.content})

        # If Claude is done, extract and return the final text
        if response.stop_reason == "end_turn":
            final_text = ""
            for block in response.content:
                if hasattr(block, "text"):
                    final_text += block.text
            print(f"\nAgent finished:\n{final_text}")
            return final_text

        # If Claude wants to use a tool, execute it and feed the result back
        if response.stop_reason == "tool_use":
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    print(f"  Calling tool: {block.name}")
                    print(f"  Input: {block.input}")

                    result = execute_tool(block.name, block.input)
                    print(f"  Result: {result[:200]}...")

                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": result,
                    })

            # Tool results go back as a "user" message so Claude can continue
            messages.append({"role": "user", "content": tool_results})
        else:
            # Unexpected stop reason - break to avoid infinite loops
            print(f"Unexpected stop reason: {response.stop_reason}")
            break

    return "Max steps reached without completion."


if __name__ == "__main__":
    blog_path = sys.argv[1] if len(sys.argv) > 1 else "sample_blog.md"
    run_agent(blog_path)

What does this code do?

This function implements three key behaviors:

  • The agentic loop: The for step in range(max_steps) loop sends messages to Claude, checks stop_reason, and either exits on end_turn or executes tools on tool_use and feeds results back.
  • Error handling: Each tool returns a JSON string with a success field. When a tool fails, Claude reads the error message and decides whether to retry or move on to the next platform.
  • Max iterations cap: The max_steps=10 parameter prevents runaway API calls. If the agent has not finished after 10 iterations, it stops and reports a failure.

What is an agentic loop?

A traditional API call is one-shot: you send a prompt, get a response, done. An agentic loop is different. You give the LLM tools and let it decide what to do next.

Claude keeps control of the workflow. It reads the blog, decides to post to X first, calls that tool, reads the result, then decides to post to Medium, and so on. Your code just executes whatever Claude asks for and feeds results back.

  • Save agent.py.
  • Verify the file has no syntax errors by running:
python -c "import agent"

If nothing prints and you return to the prompt, your code is valid. You will not see the agent in action yet because it needs a sample blog post to read.

Seeing a syntax error?

  • Check that the run_agent function is at the same indentation level as execute_tool above it (no extra indentation).
  • Make sure the f-strings use matching quotes. The messages list uses double quotes around keys and an f-string with single quotes inside.
  • Confirm that if __name__ == "__main__": is at the top level (no indentation), outside the function.

Help me debug a syntax error in my agent.py agentic loop code.

✔️ Awesome, I've got everything!

Great. Double check you have saved agent.py.

ⓧ I'd like to double check the full code

Here is the complete agent.py file. Compare yours against this:

import sys
from anthropic import Anthropic
from dotenv import load_dotenv
from tools import TOOL_FUNCTIONS

load_dotenv()

client = Anthropic()

SYSTEM_PROMPT = """You are a blog distribution agent. Your job is to take a blog post and publish adapted versions to multiple platforms.

Your workflow:
1. First, use read_blog_post to load the blog content
2. Then, adapt and post to each platform:
   - X (Twitter): Create a concise, engaging tweet (max 280 characters) that captures the key insight of the blog post. Include relevant hashtags.
   - Medium: Publish the full blog post as a draft article with an appropriate title and tags.
   - LinkedIn: Write a professional summary (1-3 paragraphs) highlighting the key takeaways, suitable for a professional audience.
3. Report the results of each posting attempt.

Important rules:
- Always read the blog post first before posting anywhere
- Adapt content appropriately for each platform's audience and format
- If a platform post fails, report the error and continue with other platforms
- Do not exceed character limits (280 for X tweets)
"""

TOOL_DEFINITIONS = [
    {
        "name": "read_blog_post",
        "description": "Read a blog post from a Markdown file on disk. Use this first to get the blog content before adapting it for each platform.",
        "input_schema": {
            "type": "object",
            "properties": {
                "file_path": {
                    "type": "string",
                    "description": "Path to the Markdown file containing the blog post",
                }
            },
            "required": ["file_path"],
        },
    },
    {
        "name": "post_to_x",
        "description": "Post a tweet to X (Twitter). The text must be 280 characters or fewer.",
        "input_schema": {
            "type": "object",
            "properties": {
                "text": {
                    "type": "string",
                    "description": "The tweet text (max 280 characters)",
                }
            },
            "required": ["text"],
        },
    },
    {
        "name": "post_to_medium",
        "description": "Publish a full article to Medium as a draft. Content should be in Markdown format.",
        "input_schema": {
            "type": "object",
            "properties": {
                "title": {
                    "type": "string",
                    "description": "The article title",
                },
                "content": {
                    "type": "string",
                    "description": "The full article content in Markdown format",
                },
                "tags": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Up to 5 tags for the article",
                },
            },
            "required": ["title", "content"],
        },
    },
    {
        "name": "post_to_linkedin",
        "description": "Post a professional update to LinkedIn. Write 1-3 paragraphs suitable for a professional audience.",
        "input_schema": {
            "type": "object",
            "properties": {
                "text": {
                    "type": "string",
                    "description": "The LinkedIn post text (1-3 professional paragraphs)",
                }
            },
            "required": ["text"],
        },
    },
]


def execute_tool(tool_name: str, tool_input: dict) -> str:
    """Execute a tool and return the result as a string."""
    if tool_name in TOOL_FUNCTIONS:
        func = TOOL_FUNCTIONS[tool_name]
        return func(**tool_input)
    return f'{{"success": false, "error": "Unknown tool: {tool_name}"}}'


def run_agent(blog_file: str, max_steps: int = 10) -> str:
    """Run the blog distribution agent."""
    messages = [
        {
            "role": "user",
            "content": f"Please read the blog post at '{blog_file}' and distribute it to X, Medium, and LinkedIn.",
        }
    ]

    print(f"\n{'='*60}")
    print(f"Blog Distribution Agent Starting")
    print(f"Blog file: {blog_file}")
    print(f"{'='*60}\n")

    for step in range(max_steps):
        print(f"--- Step {step + 1} ---")

        response = client.messages.create(
            model="claude-sonnet-4-5",
            max_tokens=4096,
            system=SYSTEM_PROMPT,
            tools=TOOL_DEFINITIONS,
            messages=messages,
        )

        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason == "end_turn":
            final_text = ""
            for block in response.content:
                if hasattr(block, "text"):
                    final_text += block.text
            print(f"\nAgent finished:\n{final_text}")
            return final_text

        if response.stop_reason == "tool_use":
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    print(f"  Calling tool: {block.name}")
                    print(f"  Input: {block.input}")

                    result = execute_tool(block.name, block.input)
                    print(f"  Result: {result[:200]}...")

                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": result,
                    })

            messages.append({"role": "user", "content": tool_results})
        else:
            print(f"Unexpected stop reason: {response.stop_reason}")
            break

    return "Max steps reached without completion."


if __name__ == "__main__":
    blog_path = sys.argv[1] if len(sys.argv) > 1 else "sample_blog.md"
    run_agent(blog_path)

Your agent is fully wired up. Next, you will create a sample blog post and watch Claude read it, adapt the content for each platform, and publish it live.

Run Your Agent and Publish a Blog Post

Your agent is fully assembled. The tools are built, the agent loop is wired up, and Claude knows how to adapt content for each platform.

Now it is time to feed it a real blog post and watch it work. You will create a sample Markdown file, run the agent, and verify that posts appear on X, Medium, and LinkedIn.

In this step, get ready to:
  • Create a sample blog post in Markdown.
  • Run the agent and watch it read, adapt, and publish your content.
  • Verify that posts appeared on each platform.
Create a sample blog post

The agent needs a Markdown file to read. You will create a short blog post about building AI agents that gives Claude enough content to work with across all three platforms.

  • Create a new file called sample_blog.md in your project folder and paste in the following content:
# Building My First AI Agent: Lessons Learned

I recently built my first AI agent using Claude's tool use API, and I want to share what I learned along the way.

## The Core Pattern

An AI agent is surprisingly simple at its core. It is just a loop:

1. Send a message to the LLM with available tools
2. If the LLM wants to call a tool, execute it
3. Feed the result back
4. Repeat until done

## What Surprised Me

The hardest part was not the agent loop itself. It was designing good tool descriptions. Claude reads your tool descriptions to decide when and how to use them. Vague descriptions lead to wrong tool calls.

## Key Takeaways

- Start with 2-3 tools maximum
- Write tool descriptions like you are explaining to a new team member
- Always add a max iteration cap to prevent runaway costs
- Test with simple cases before going complex

## What I Would Do Differently

Next time, I would spend more time on error handling in tool results. When a tool fails, the agent needs clear error messages to decide what to do next.

The agentic pattern is the foundation of modern AI automation. Once you understand the loop, you can build anything from code reviewers to data pipelines.

Why this blog post?

This post has enough substance for Claude to pull out a concise tweet, publish a full article on Medium, and write a professional LinkedIn summary. It covers a technical topic with clear takeaways, which makes it easy for the agent to adapt for different audiences.

  • Save sample_blog.md.
  • Confirm the file exists by running:
ls sample_blog.md
  • You should see sample_blog.md listed in the output.
Run the agent

With your blog post ready and your API credentials configured, you can launch the agent. It will read the file, decide how to adapt the content, and call each platform's posting tool autonomously.

  • Make sure your virtual environment is still activated (you should see the environment name in your terminal prompt).
  • Run the agent by passing your blog post file as an argument:
python agent.py sample_blog.md

What should I see?

The terminal shows each step of the agentic loop in real time. You will see output like:

  • Step 1: Claude calls read_blog_post to load the Markdown file.
  • Step 2: Claude calls post_to_x with a tweet it crafted from the blog content.
  • Step 3: Claude calls post_to_medium with the full article and tags.
  • Step 4: Claude calls post_to_linkedin with a professional summary.
  • Final step: Claude reports the results of all posting attempts and stops (end_turn).

What is happening under the hood?

Each time Claude returns a tool_use stop reason, your run_agent() function executes the tool and feeds the result back as a tool_result message. Claude uses that result to decide what to do next.

This is the core agentic pattern: the LLM is in the driver's seat, choosing which tools to call and in what order. Your code only provides the tools and the loop.

Seeing an error?

  • If you see a FileNotFoundError for the blog post, make sure sample_blog.md is in the same folder where you are running the command.
  • If you see an authentication error from the Anthropic API, double-check that your ANTHROPIC_API_KEY in .env is correct and has available credits.
  • If a platform post fails (e.g. X returns an HTTP error), the agent will report the failure and continue with the other platforms. Check your credentials for that platform in .env.

Help me debug my agent run

Verify posts on each platform

The terminal output shows success messages, but you should verify that the posts actually appeared on each platform. This confirms your API credentials are working and the content was published correctly.

  • Open X (Twitter) and navigate to your profile page.
  • Confirm you see a new tweet summarizing the blog post with relevant hashtags.
  • Open Medium and click on your profile avatar, then select Stories.
  • Look under the Drafts tab for a new article with the blog post content.
  • Open LinkedIn and check your recent activity for a professional summary of the blog post.

LinkedIn is optional

If you did not configure LinkedIn credentials, the agent will report that LinkedIn credentials are not configured and continue with X and Medium. This is expected behaviour. You can add LinkedIn later when your developer app is approved.

Post missing on a platform?

  • Check the terminal output for that platform's tool result. A "success": false with an HTTP error code tells you exactly what went wrong.
  • For X, a 403 error usually means your app does not have write permissions. Go back to your X Developer Portal and confirm the app has Read and Write access.
  • For Medium, a 401 error means your integration token is invalid or expired. Generate a new one from your Medium settings.

Help me figure out why my post did not appear

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files.

ⓧ I'd like to double check the full code

Here is the complete sample_blog.md file (the only new file in this step):

# Building My First AI Agent: Lessons Learned

I recently built my first AI agent using Claude's tool use API, and I want to share what I learned along the way.

## The Core Pattern

An AI agent is surprisingly simple at its core. It is just a loop:

1. Send a message to the LLM with available tools
2. If the LLM wants to call a tool, execute it
3. Feed the result back
4. Repeat until done

## What Surprised Me

The hardest part was not the agent loop itself. It was designing good tool descriptions. Claude reads your tool descriptions to decide when and how to use them. Vague descriptions lead to wrong tool calls.

## Key Takeaways

- Start with 2-3 tools maximum
- Write tool descriptions like you are explaining to a new team member
- Always add a max iteration cap to prevent runaway costs
- Test with simple cases before going complex

## What I Would Do Differently

Next time, I would spend more time on error handling in tool results. When a tool fails, the agent needs clear error messages to decide what to do next.

The agentic pattern is the foundation of modern AI automation. Once you understand the loop, you can build anything from code reviewers to data pipelines.

Secret mission

Add Dry Run Mode and Scheduled Publishing

Your agent works, but would you really let it post to all your accounts without previewing first? In this extension, you'll add a safety net with dry run mode and a scheduling system that queues posts for later.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project uses pay-per-use APIs. Your X Developer account holds a $5 credit balance that depletes at $0.01 per post. Anthropic charges per API call (roughly $0.05-0.10 per agent run).

Cost warning

Your X API credits and Anthropic API balance will only be charged when you actively run the agent script. There are no idle charges while the script is not running.

However, if you share your API keys or leave them in an unsecured location, someone else could use them and incur charges on your accounts.

Resources you used:

  • Local project files (tools.py, agent.py, sample_blog.md, .env, requirements.txt).
  • Python virtual environment.
  • Anthropic API key.
  • X Developer App and API credentials.
  • Medium Integration Token.
  • LinkedIn Developer App (if configured).

Keep everything running

No action needed. Choose this if you plan to keep distributing blog posts with your agent.

  • Your API keys remain active and ready to use whenever you run the script again.
  • Your X credits stay in your account until used.
  • Your local project files stay on your Desktop.
  • Keep your .env file secure. Never commit it to a public repository.

Pause - I'll come back to this later

Revoke your API keys to prevent accidental usage while keeping your project files intact.

  • In the Anthropic Console, navigate to API Keys and delete or disable your key. You can generate a new one when you return.
  • In the X Developer Portal, navigate to your App's Keys and tokens section and regenerate your keys (this invalidates the old ones).
  • On Medium, go to Settings then Integration Tokens and revoke your token.
  • Your local project files remain on your Desktop. You can regenerate new keys later and update your .env file.

Delete - I don't want to use this again

Remove all project resources and revoke all API access.

  • Delete your Anthropic API key from the Anthropic Console under API Keys.
  • In the X Developer Portal, navigate to your Project, select your App, and click Delete App to remove the app and invalidate all credentials.
  • On Medium, go to Settings then Integration Tokens and revoke your token.
  • If you set up LinkedIn, go to the LinkedIn Developer Portal, select your app, and click Delete Application.
  • Delete your local project files. On macOS/Linux, run:
cd ~/Desktop
rm -rf blog-distributor

On Windows, run:

cd ~/Desktop
rmdir /s /q blog-distributor
  • Clear the environment variables from your shell session by closing your terminal window.

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully functional AI agent that reads a blog post and publishes adapted versions across multiple platforms, all without you lifting a finger after hitting Enter.

You've learned how to:

  • Built a Python AI agent using Claude's tool use feature to autonomously decide which tools to call and when.
  • Integrated with three real platform APIs (X, Medium, and LinkedIn) using OAuth 1.0a, Integration Tokens, and OAuth 2.0.
  • Implemented the core agentic loop pattern that powers all modern AI agent systems: send, check for tool calls, execute, feed results back, repeat.
  • Added a dry-run mode and scheduled publishing system to preview and queue posts for later.

Ready to quiz yourself?