Daily LinkedIn Post Agent on AWS

Build an AI agent that drafts daily LinkedIn posts from trending news.

Introduction

30 Second Summary

Posting consistently on LinkedIn is one of the best ways to grow your professional network. The hard part is showing up every single day with something fresh and relevant to say.

In this project, you will build an AI agent that researches trending news in your field each morning, writes a LinkedIn post draft in your voice, and delivers it straight to your inbox.

What You'll Build

You open your email tomorrow morning and find a ready-to-post LinkedIn draft waiting, written in your voice, grounded in a real trending headline from your industry.

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

  • A daily post draft in your inbox each morning that references real trending headlines and sounds like you wrote it.
  • A domain relevance filter that scores every headline against your CV so the agent only writes about topics you can credibly speak about.
  • A post history tracker in the cloud that remembers recent topics and guarantees fresh content every day.
  • Secret Mission: A self-evaluating quality gate that catches hallucinated credentials or off-topic drift before drafts reach your inbox.

Are there any prerequisites?

You will need Python 3.11 installed locally, an AWS account with browser access, and an NVIDIA NIM API key already created. Step 1 walks you through creating your GNews account and preparing your project files.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters for your professional presence.

Set Up GNews and Create Project Files

Your agent needs two ingredients before it can write anything useful: a live stream of news headlines, and your personal context.

Without news, there is nothing to react to. Without your CV and writing style, the output sounds generic. In this step, you will sign up for GNews (a free news API), create your project folder, and fill in the files that teach the agent who you are and how you write.

In this step, get ready to:
  • Sign up for GNews and grab your API key.
  • Create your project folder with personal context files.
  • Verify GNews returns live headlines.
Sign up for GNews and get your API key

GNews is a REST API that gives you access to top news headlines from over 80,000 sources worldwide. Your agent will use it to fetch trending stories every day.

  • Open your browser and go to gnews.io/register.
  • Enter your email address and a password, then click Sign up. You can also sign up with Google.
  • Check your inbox for a verification email from GNews and confirm your email address.
  • Once verified, sign in at gnews.io/login.
  • On your GNews dashboard, locate your API key and copy it to a safe place (a text file or note).

What is GNews?

GNews is a real-time news API that returns top headlines ranked by Google News. It offers 100 free requests per day on the free plan, which is more than enough for one daily agent run plus testing.

Not receiving the verification email?

Check your spam or junk folder. The email comes from GNews and may take a minute to arrive. If it still does not show up, try signing up with Google instead.

help me troubleshoot my GNews signup

Create your project folder and files

Your project lives in a single folder containing three files: the main script, your CV, and your writing style guide.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar, type Terminal (macOS) or PowerShell (Windows), and press Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir linkedin-post-agent
cd linkedin-post-agent
  • Create the three project files by running this command:

macOS

touch lambda_function.py cv.md humanizer.md

Windows

New-Item lambda_function.py, cv.md, humanizer.md -ItemType File
  • Confirm the files exist by running:
ls

You should see lambda_function.py, cv.md, and humanizer.md listed in the output.

Don't see all three files?

Make sure you ran the create command from inside the linkedin-post-agent folder. Run pwd (macOS) or Get-Location (Windows) to check your current directory. It should end with linkedin-post-agent.

help me find my project files

Now you will populate the two context files that teach the agent about you.

  • Open cv.md in the text editor of your choice and paste the following template:
# Your Name - Professional CV

## Summary
[Paste your professional summary here]

## Experience
[Paste your work experience here]

## Skills
[List your technical and professional skills]

## Education
[Your education background]

## Certifications
[Any relevant certifications]

What does cv.md do for the agent?

The agent reads this file to understand your professional background. It uses your CV to score news headlines for relevance and to avoid claiming credentials you do not have. The more detailed your CV, the better the agent filters topics.

Replace every bracketed section with your real information. Markdown formatting is fine. A few bullet points per section is enough.

  • Replace the bracketed placeholder sections with your actual professional information.
  • Save cv.md.
  • Open humanizer.md in your text editor and paste this template:
# LinkedIn Writing Style Guide

## Tone
- Conversational but professional
- Share genuine opinions, not generic advice
- Use first person ("I noticed...", "In my experience...")

## Structure
- Open with a hook: a bold statement, question, or surprising observation
- Keep paragraphs to 1-2 sentences max (LinkedIn mobile readability)
- End with a question or call to discussion
- Total length: 150-250 words

## Rules
- No emojis (or specify which ones you use)
- No hashtags in the body, only 3-5 at the end
- Never use "excited to announce" or "thrilled to share"
- Avoid jargon unless explaining it
- Reference personal experience when possible

## Topics I cover
- [Your domain topics here]

## Topics I avoid
- [Topics outside your expertise]

What does humanizer.md do for the agent?

This file is your writing style guide. The agent reads it as instructions for how to write in your voice. If your LinkedIn posts are short and punchy, say so. If you never use emojis, say so. The more specific you are, the more the generated drafts sound like you.

Customize the template to match how you actually write on LinkedIn. Replace the bracketed items under "Topics I cover" and "Topics I avoid" with your real preferences.

  • Update the bullet points under each section to match your personal writing style and topic preferences.
  • Save humanizer.md.

✔️ Awesome, I've got everything!

Great. Double check you have saved both cv.md and humanizer.md with your personalized content. The lambda_function.py file stays empty for now.

ⓧ I'd like to double check the full code

Your cv.md should have headings for Summary, Experience, Skills, Education, and Certifications, each filled with your own information. Your humanizer.md should have sections for Tone, Structure, Rules, Topics I cover, and Topics I avoid, personalized to how you write. The lambda_function.py file should exist but remain empty.

Verify GNews returns headlines

The last step is confirming your GNews API key works by fetching some live headlines.

  • Install the requests library by running this command in your terminal:
pip install requests

Seeing 'pip: command not found'?

Try pip3 install requests instead. On some systems, the Python 3 pip is named pip3. If both fail, make sure Python 3.11 is installed and added to your system PATH.

help me fix pip not being found

Before you run the next command, what HTTP status code do you expect to see if your API key is valid and the request succeeds?

  • Test your GNews API key by running this command (replace your-key-here with the API key you copied from the GNews dashboard):
python -c "import requests; print(requests.get('https://gnews.io/api/v4/top-headlines?category=technology&lang=en&max=3&apikey=your-key-here').status_code)"

You should see 200 printed in your terminal. This confirms your GNews account is active and your API key is working.

What does this command do?

This one-liner imports the requests library, sends a GET request to the GNews top-headlines endpoint filtered to the technology category, and prints just the HTTP status code. A 200 means success.

Seeing 401 instead of 200?

A 401 means your API key is missing or invalid. Double check you replaced your-key-here with the actual key from your GNews dashboard. Look for typos or extra spaces around the key.

If you see 403, you have hit your daily request quota (100 requests on the free plan). The quota resets at 00:00 UTC.

help me fix my GNews API key error

Your news source is live and your personal context files are ready. Next up, you will write the core agent logic that turns these headlines into a LinkedIn post draft.

Build the Agent Locally

Your project folder is ready with your CV and style guide. Now comes the core challenge: turning those personal files into an agent that reads today's news and writes a LinkedIn post in your voice.

In this step, you will build the full agent pipeline locally. It fetches headlines, scores them against your expertise, and generates a polished post using NVIDIA NIM. Running it locally first means you see results instantly and can iterate before touching AWS.

In this step, get ready to:
  • Write the core agent functions that fetch news and generate a post via NVIDIA NIM.
  • Add domain relevance filtering so the agent only writes about topics in your CV.
  • Test the full pipeline locally and see a LinkedIn post draft in your terminal.
Write the core agent functions

The agent needs two Python libraries that are not part of the standard library: openai for calling NVIDIA NIM, and boto3 for AWS services you will wire up in later steps.

  • Install the required libraries by running this command in your project folder:
pip install openai boto3

What does this install?

The openai library gives you a client that speaks the OpenAI-compatible API format. NVIDIA NIM uses this same format, so you point the client at NIM's URL instead of OpenAI's.

The boto3 library is the official AWS SDK for Python. You will use it for DynamoDB and SNS in later steps, but the import needs to be present now.

Seeing a permission error?

On some systems, pip install requires elevated permissions. Try adding --user to the end of the command to install into your user directory instead.

Help me fix a pip permission error when installing openai and boto3.

The agent reads API keys from environment variables so they stay out of your code. You need to set these before running the script.

  • Set your environment variables for local testing:

macOS

export NVIDIA_API_KEY=your-nvidia-api-key-here
export GNEWS_API_KEY=your-gnews-api-key-here
export SNS_TOPIC_ARN=placeholder

Windows

$env:NVIDIA_API_KEY="your-nvidia-api-key-here"
$env:GNEWS_API_KEY="your-gnews-api-key-here"
$env:SNS_TOPIC_ARN="placeholder"

Why set SNS_TOPIC_ARN to placeholder?

Your code imports the SNS_TOPIC_ARN variable at the top of the file. The actual topic does not exist yet (you create it in Step 5). Setting it to any string prevents the script from crashing on startup.

Now open lambda_function.py in your editor. This file is currently empty. You will build it up function by function.

  • Paste the following imports and configuration block at the top of lambda_function.py:
import json
import os
import datetime
import boto3
import requests
from openai import OpenAI

# Configuration from environment variables
NVIDIA_API_KEY = os.environ["NVIDIA_API_KEY"]
GNEWS_API_KEY = os.environ["GNEWS_API_KEY"]
SNS_TOPIC_ARN = os.environ["SNS_TOPIC_ARN"]
DYNAMODB_TABLE = os.environ.get("DYNAMODB_TABLE", "linkedin-post-history")

# NVIDIA NIM client (OpenAI-compatible)
client = OpenAI(
    base_url="https://integrate.api.nvidia.com/v1",
    api_key=NVIDIA_API_KEY
)

# AWS clients
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(DYNAMODB_TABLE)
sns = boto3.client("sns")

What does this code do?

The imports pull in everything the agent needs: json for parsing API responses, requests for calling GNews, and OpenAI for the NIM client.

The OpenAI client points at https://integrate.api.nvidia.com/v1 instead of OpenAI's servers. This is the NVIDIA NIM hosted endpoint that speaks the same API format.

The boto3 resource and client objects are created here but do not make network calls yet. They are ready for when you add DynamoDB and SNS logic later.

  • Below the configuration block, add the file-loading and news-fetching functions:
def load_file(filename):
    """Load a markdown file bundled with the Lambda package."""
    filepath = os.path.join(os.path.dirname(__file__), filename)
    with open(filepath, "r", encoding="utf-8") as f:
        return f.read()


def fetch_news():
    """Fetch top headlines from GNews API."""
    url = "https://gnews.io/api/v4/top-headlines"
    params = {
        "category": "technology",
        "lang": "en",
        "max": 10,
        "apikey": GNEWS_API_KEY
    }
    response = requests.get(url, params=params, timeout=15)
    response.raise_for_status()
    data = response.json()
    return data.get("articles", [])

What does this code do?

The load_file() function reads your cv.md and humanizer.md files from the same directory as the script. It uses __file__ so it works both locally and inside AWS Lambda.

The fetch_news() function calls the GNews top-headlines endpoint filtered to the technology category. It returns up to 10 articles as a list of dictionaries, each containing a title and description.

  • Save lambda_function.py.
  • Verify that news fetching works by running this command:
python -c "from lambda_function import fetch_news; articles = fetch_news(); print(f'Fetched {len(articles)} articles'); print(articles[0]['title'])"

You should see a count of articles (up to 10) followed by a real headline from today's tech news.

Seeing a KeyError or import error?

If you see KeyError: 'NVIDIA_API_KEY', your environment variables are not set in this terminal session. Re-run the export commands from earlier in the same terminal window.

If you see ModuleNotFoundError: No module named 'openai', run pip install openai boto3 again and make sure you are using the same Python that pip installed to.

Help me debug why my lambda_function.py won't import.

Now add the function that turns a headline into a LinkedIn post by calling NVIDIA NIM.

  • Below fetch_news(), add the generate_post() function:
def generate_post(article, cv_text, humanizer_text, recent_topics):
    """Generate a LinkedIn post using NIM."""
    recent_topics_str = ", ".join(recent_topics) if recent_topics else "None yet"
    response = client.chat.completions.create(
        model="moonshotai/kimi-k2.6",
        messages=[
            {
                "role": "system",
                "content": f"""You are a LinkedIn ghostwriter. Your rules:
1. Only write about topics the user has expertise in based on their CV.
2. Ground the post in the provided news headline - reference it naturally.
3. Follow the humanizer style guide exactly for tone, length, and formatting.
4. Do NOT claim credentials or experiences not in the CV.
5. Do NOT repeat these recent topics: {recent_topics_str}
6. Output ONLY the post text, ready to paste into LinkedIn. No preamble.

Humanizer Style Guide:
{humanizer_text}"""
            },
            {
                "role": "user",
                "content": f"CV:\n{cv_text}\n\nHeadline: {article['title']}\nDescription: {article.get('description', '')}\n\nWrite a LinkedIn post about this news from my professional perspective."
            }
        ],
        temperature=0.7,
        max_tokens=1024
    )
    return response.choices[0].message.content

What does this code do?

This function calls NVIDIA NIM with the model moonshotai/kimi-k2.6. The system prompt contains your humanizer style guide and instructs the model to write as you, grounded in the headline.

The recent_topics parameter is empty for now. In Step 3, you will fill it with topics from DynamoDB to prevent repetition.

  • Save lambda_function.py.
  • Test that post generation works by running this command with a dummy headline:
python -c "from lambda_function import generate_post, load_file; cv = load_file('cv.md'); h = load_file('humanizer.md'); print(generate_post({'title': 'AI Transforms Cloud Computing in 2026', 'description': 'New developments emerge'}, cv, h, []))"

You should see a LinkedIn-style post printed to your terminal, written from your professional perspective about the dummy headline.

Getting a 401 or authentication error?

Double-check that your NVIDIA_API_KEY environment variable is set correctly. Run echo $NVIDIA_API_KEY (macOS) or echo $env:NVIDIA_API_KEY (Windows) to confirm it shows your key.

Help me fix an authentication error when calling NVIDIA NIM.

Add domain relevance filtering

Right now, the agent would generate a post about any headline, even ones outside your expertise. You need a filter that scores each headline against your CV and only passes the relevant ones to the generation step.

  • Below fetch_news() and above generate_post(), add the score_relevance() function:
def score_relevance(articles, cv_text):
    """Score each article for relevance to the CV using NIM."""
    headlines = "\n".join(
        [f"{i+1}. {a['title']}" for i, a in enumerate(articles)]
    )
    response = client.chat.completions.create(
        model="moonshotai/kimi-k2.6",
        messages=[
            {
                "role": "system",
                "content": "You are a relevance scorer. Given a CV and a list of headlines, rate each headline 1-5 for how relevant it is to the person's professional expertise. Respond with JSON only: [{\"index\": 1, \"score\": 5, \"topic\": \"short topic label\"}, ...]"
            },
            {
                "role": "user",
                "content": f"CV:\n{cv_text}\n\nHeadlines:\n{headlines}"
            }
        ],
        temperature=0.3,
        max_tokens=1024
    )
    content = response.choices[0].message.content
    try:
        scores = json.loads(content)
    except json.JSONDecodeError:
        start = content.find("[")
        end = content.rfind("]") + 1
        scores = json.loads(content[start:end])
    return scores

What does this code do?

This function sends all headlines plus your CV to NIM and asks it to rate each headline 1-5 for relevance to your professional expertise. It returns a list of scores as JSON.

The try/except block handles cases where the model wraps its JSON in extra text. It finds the JSON array boundaries and parses just that portion.

  • Save lambda_function.py.
  • Verify the scoring works by running this command:
python -c "from lambda_function import fetch_news, score_relevance, load_file; articles = fetch_news(); cv = load_file('cv.md'); scores = score_relevance(articles, cv); print(scores[:3])"

You should see a list of dictionaries, each with an index, score, and topic field. Headlines relevant to your CV get scores of 4 or 5.

Getting a JSONDecodeError?

If the model returns badly formatted JSON, the fallback parser in score_relevance() should handle it. If you still see an error, try running the command again. LLM responses can vary between calls.

Help me debug a JSON parsing error from my score_relevance function.

Tie it all together and test the full pipeline

You have all the building blocks: fetching news, scoring relevance, and generating posts. The handler function orchestrates these into a single pipeline that runs end to end.

  • At the bottom of lambda_function.py, add the first part of the handler function:
def lambda_handler(event, context):
    """Main Lambda handler - orchestrates the full agent pipeline."""
    # Load personal context
    cv_text = load_file("cv.md")
    humanizer_text = load_file("humanizer.md")

    # Fetch news
    articles = fetch_news()
    if not articles:
        return {"statusCode": 200, "body": "No articles found today."}

    # Score relevance
    scores = score_relevance(articles, cv_text)

    # Filter to relevant articles (score >= 4)
    relevant = [
        s for s in scores
        if s.get("score", 0) >= 4
    ]

    if not relevant:
        return {"statusCode": 200, "body": "No relevant new topics."}

What does this code do?

The handler loads your CV and humanizer files, fetches today's headlines, then scores them. Any headline scoring below 4 is discarded. If nothing relevant remains, the function exits early.

  • Directly below the if not relevant return block (still inside the lambda_handler function), add the selection and generation logic:
    # Pick the highest-scoring article
    relevant.sort(key=lambda x: x.get("score", 0), reverse=True)
    best = relevant[0]
    article_index = best["index"] - 1
    chosen_article = articles[article_index]
    chosen_topic = best["topic"]

    # Generate the post
    post_text = generate_post(chosen_article, cv_text, humanizer_text, [])

    print(post_text)
    return {
        "statusCode": 200,
        "body": json.dumps({
            "topic": chosen_topic,
            "post_preview": post_text[:200]
        })
    }

What does this code do?

This block picks the highest-scoring headline, passes it to generate_post() along with your CV and humanizer guide, then prints the resulting post to the terminal. The return value is formatted for AWS Lambda but also works locally.

  • Save lambda_function.py.

Before you run the full pipeline, what do you think you will see? A generic AI post, or something grounded in a real headline and filtered to your expertise?

  • Run the full agent pipeline by executing this command:
python -c "from lambda_function import lambda_handler; lambda_handler(None, None)"

You should see a full LinkedIn post draft printed to your terminal. It references a real trending headline from today and is written in the style you defined in humanizer.md.

Seeing 'No relevant new topics' instead of a post?

This means none of today's technology headlines scored 4 or higher against your CV. Open cv.md and make sure it contains real professional content. A placeholder CV gives the model nothing to match against.

If your CV is populated but still no match, try changing the category in fetch_news() to general or business temporarily to broaden the headline pool.

Help me figure out why my agent is not finding relevant topics.

✔️ Awesome, I've got everything!

Great. Double check you have saved lambda_function.py with all functions present.

ⓧ I'd like to double check the full code

Your lambda_function.py should contain these sections in order. Scroll up and confirm each one is present:

  • Imports and configuration block (lines 1-23): six imports, four environment variable reads, the OpenAI client, and three boto3 objects.
  • The load_file() function that reads a markdown file from the script's directory.
  • The fetch_news() function that calls the GNews top-headlines endpoint with category technology.
  • The score_relevance() function that rates headlines 1-5 against your CV.
  • The generate_post() function that calls NIM with the ghostwriter system prompt.
  • The lambda_handler() function that orchestrates the full pipeline: load files, fetch news, score, filter, pick best, generate, print.

Your agent works end to end on your machine. It reads today's news, picks what is relevant to you, and drafts a LinkedIn post in your voice. Next up, you will give it memory so it never repeats the same topic twice.

Add Post History with DynamoDB

Your agent produces a LinkedIn post every time it runs. But without any memory, it has no idea what it wrote yesterday.

If the same headline stays trending for a few days, the agent will happily write about it again and again. A DynamoDB table gives the agent a history of past topics so it can guarantee every post covers fresh ground.

In this step, get ready to:
  • Create a DynamoDB table in the AWS Console to store post history.
  • Add history-checking logic that scans the last 14 days of posts.
  • Save each generated post to the table so future runs avoid repeated topics.
Create the DynamoDB table

The agent needs a place to store which topics it has already covered. You will create a single DynamoDB table with the post date as the primary key.

  • Open the AWS Management Console in your browser and search for DynamoDB in the top search bar.
  • Click on DynamoDB to open the service console.
  • In the left navigation pane, click Tables.
  • Click Create table.
  • For Table name, enter linkedin-post-history.
  • For Partition key, enter post_date and leave the type set to String.
  • Leave the Sort key field empty.
  • For Table settings, keep the default selection of Default settings.
  • Click Create table.

Wait until the table status shows Active in the tables list. This usually takes a few seconds.

What is DynamoDB?

DynamoDB is a fully managed NoSQL database from AWS. It stores items (rows) in tables, and each item is identified by a primary key.

Your table uses post_date as the partition key, meaning each date gets one entry. The free tier includes 25 GB of storage and 25 read/write capacity units per month, which is far more than this agent will ever use.

Don't see the Create table button?

Make sure you are in the Tables section of the DynamoDB console (left sidebar), not the dashboard overview.

Also confirm you are signed into the correct AWS account and region. Help me find the Create table button in DynamoDB.

Add the history functions to your code

Now you will add two new functions to your agent. One reads recent post topics from the table. The other writes a new entry after generating a post.

  • Open lambda_function.py in your editor.
  • Find the closing line of the fetch_news function (the return data.get("articles", []) line).
  • Add the following function directly below it, with a blank line in between:
def get_recent_topics():
    """Get topics from the last 14 days to avoid repetition."""
    today = datetime.date.today()
    fourteen_days_ago = today - datetime.timedelta(days=14)
    response = table.scan(
        FilterExpression="post_date >= :start_date",
        ExpressionAttributeValues={":start_date": fourteen_days_ago.isoformat()}
    )
    return [item["topic"] for item in response.get("Items", [])]

What does this function do?

It scans the DynamoDB table for any posts saved in the last 14 days. It collects just the topic labels from those entries and returns them as a list.

Later, the agent passes this list to the NIM prompt so it knows which subjects to avoid.

  • Now find the closing line of the generate_post function (the return response.choices[0].message.content line).
  • Add the following function directly below it, with a blank line in between:
def save_post(topic, post_text):
    """Save the post to DynamoDB for history tracking."""
    today = datetime.date.today().isoformat()
    table.put_item(Item={
        "post_date": today,
        "topic": topic,
        "post_preview": post_text[:100],
        "full_post": post_text
    })

What does this function do?

After the agent generates a post, save_post writes an item to DynamoDB with today's date, the chosen topic, a preview of the post (first 100 characters), and the full post text.

Next time the agent runs, get_recent_topics will find this entry and exclude the topic from consideration.

  • Verify your new functions parse correctly by running:
python -c "import ast; ast.parse(open('lambda_function.py').read()); print('No syntax errors')"

You should see No syntax errors printed to your terminal.

Seeing a SyntaxError?

Check that you placed each new function after the closing return of the previous function, not inside it. Python is sensitive to indentation. Both new functions should start at column 0 (no indent).

Help me fix a SyntaxError in my lambda_function.py after adding get_recent_topics and save_post.

Wire history into the handler and verify

The two functions exist, but the handler does not call them yet. You need to update lambda_handler to read history before filtering, exclude recent topics, and save after generating.

  • In lambda_function.py, scroll down to the lambda_handler function.
  • Find this line:
    # Score relevance
    scores = score_relevance(articles, cv_text)
  • Add the following two lines directly above # Score relevance:
    # Get recent topics to avoid
    recent_topics = get_recent_topics()
  • Next, find the filter block that looks like this:
    # Filter to relevant articles (score >= 4)
    relevant = [
        s for s in scores
        if s.get("score", 0) >= 4
    ]
  • Replace it with this updated version that also excludes recent topics:
    # Filter to relevant articles (score >= 4) and exclude recent topics
    relevant = [
        s for s in scores
        if s.get("score", 0) >= 4 and s.get("topic", "") not in recent_topics
    ]
  • Find the generate_post call near the bottom of the handler. It currently looks like:
    post_text = generate_post(chosen_article, cv_text, humanizer_text, [])
  • Replace [] with recent_topics, so the line now looks like:
    post_text = generate_post(chosen_article, cv_text, humanizer_text, recent_topics)
  • Finally, find this line:
    print(post_text)
  • Replace it with:
    # Save to history
    save_post(chosen_topic, post_text)

What changed in the handler?

The handler now calls get_recent_topics() to load the last 14 days of history. It passes those topics to the filter (so they are excluded from headline selection) and to the generation prompt (so the LLM is also warned).

After generating a post, it saves the topic and text to DynamoDB via save_post(). The next run will see today's topic in history and pick a different one.

  • Save lambda_function.py.
  • To test locally, your script needs AWS credentials so it can reach DynamoDB. Set the following environment variables in your terminal (replace the placeholder values with your actual credentials):
export AWS_ACCESS_KEY_ID=your-access-key-id-here
export AWS_SECRET_ACCESS_KEY=your-secret-access-key-here
export AWS_DEFAULT_REGION=us-east-1
export SNS_TOPIC_ARN=placeholder

Where do I find my AWS credentials?

In the AWS Console, click your account name in the top right corner, then Security credentials. Under Access keys, create a new access key if you do not already have one. Copy the Access Key ID and Secret Access Key.

The SNS_TOPIC_ARN is set to a placeholder because you have not created the SNS topic yet. The agent does not call SNS in this step.

Before you run the agent, what do you think will happen? The DynamoDB table is empty, so the history check should find zero topics to exclude. The agent should produce a post just like before.

  • Run the agent for the first time by executing:
python -c "from lambda_function import lambda_handler; import json; result = lambda_handler({}, None); print(json.dumps(result, indent=2))"

You should see a JSON response with a topic field and a post_preview field containing the first few characters of your generated post.

  • Now run it a second time with the same command:
python -c "from lambda_function import lambda_handler; import json; result = lambda_handler({}, None); print(json.dumps(result, indent=2))"

Compare the topic field from both runs. The second run should pick a different topic because the first topic is now saved in DynamoDB and excluded from consideration.

Both runs show the same topic?

Check that save_post(chosen_topic, post_text) appears in your handler BEFORE the return statement. If the save happens after the return, the item never reaches DynamoDB.

Also confirm your AWS_DEFAULT_REGION matches the region where you created the table. If the regions differ, the scan finds an empty table.

My agent produces the same topic on both runs. Help me debug DynamoDB save_post and get_recent_topics.

✔️ Awesome, I've got everything!

Great. Double check you have saved lambda_function.py and both runs returned different topics.

ⓧ I'd like to double check the full code

Your lambda_function.py is long. Scroll through and confirm each section is present in this order:

  • Imports and configuration (lines 1-20).
  • The load_file function.
  • The fetch_news function.
  • The get_recent_topics function (new this step).
  • The score_relevance function.
  • The generate_post function.
  • The save_post function (new this step).
  • The lambda_handler function calling get_recent_topics(), passing recent_topics to the filter and generate_post, and calling save_post before returning.

Your agent now remembers what it wrote. Every run checks the last two weeks of history and steers toward fresh topics. Next up, you will deploy this agent to AWS Lambda so it can run without your laptop being on.

Deploy to AWS Lambda

Your agent runs perfectly on your machine, but for daily unattended content generation it needs to live in the cloud.

AWS Lambda lets you run code without managing servers. You package your project as a zip file, upload it, and Lambda executes it on demand. In this step, you'll move the agent into Lambda so it can run on a schedule without your laptop being open.

In this step, get ready to:
  • Package your Python dependencies into a deployment zip.
  • Create a Lambda function in the AWS Console.
  • Configure environment variables, IAM permissions, and test the function.
Package your dependencies for Lambda

Lambda runs your code in an isolated environment that only has boto3 pre-installed. Any other libraries your code imports need to be bundled inside the zip alongside your function file.

  • Open your terminal and navigate to your project folder (the one containing lambda_function.py, cv.md, and humanizer.md).
  • Install the openai and requests libraries directly into the project folder by running this command:
pip install openai requests -t .

What does -t . do?

The -t . flag tells pip to install packages into the current directory instead of your system's site-packages. This puts the library files right next to your code so Lambda can find them when it runs your function.

  • Confirm the packages installed into your folder by running:
ls

You should see new folders like openai and requests alongside your lambda_function.py file.

Don't see the new folders?

Make sure you ran the command from inside your project folder. If you ran it from a different directory, the libraries installed there instead. Navigate to your project folder with cd and try again.

If you see a permission error, try pip3 install openai requests -t . instead.

Help me troubleshoot my pip install

Now you need to compress everything into a single zip file. The key rule: all files must be at the root of the zip, not inside a subfolder.

macOS

  • Create the zip by running this command from inside your project folder:
zip -r lambda-package.zip .

This creates lambda-package.zip containing all your files at the root level.

Windows

  • Open File Explorer and navigate to your project folder.
  • Select all files and folders inside the project folder (Ctrl+A).
  • Right-click the selection, then choose Compress to ZIP file.
  • Rename the zip to lambda-package.zip.

Select the contents, not the folder

Do not right-click the project folder itself. You must select the files inside it. Lambda needs lambda_function.py at the zip root, not nested inside a subfolder.

Create the Lambda function
  • In the AWS Management Console, search for Lambda in the top search bar and open the Lambda service.
  • Click Create function.
  • Select Author from scratch.
  • For Function name, enter linkedin-post-agent.
  • For Runtime, choose Python 3.11.
  • Leave Architecture set to x86_64.
  • Under Permissions, leave the default Create a new role with basic Lambda permissions selected.
  • Click Create function.

You should see a success banner and the function's detail page with a Code tab open.

What did this just create?

Lambda created your function with a default execution role that includes the AWSLambdaBasicExecutionRole policy. This allows your function to write logs to CloudWatch. You'll add DynamoDB permissions shortly.

  • Upload your zip by clicking Upload from in the Code source pane.
  • Select .zip file.
  • Click Upload, select your lambda-package.zip, and click Open.
  • Click Save.

After the upload completes, you should see lambda_function.py in the code editor along with your cv.md and humanizer.md files in the file explorer on the left.

Don't see your files in the editor?

If you see a single folder instead of your files at the root, your zip contained the project folder itself rather than its contents. Re-create the zip by selecting the files inside the folder, not the folder.

Help me fix my Lambda zip upload

Configure environment variables, permissions, and test

Your function code reads API keys and resource names from environment variables. You need to set these in the Lambda configuration so the function can find them at runtime.

  • Click the Configuration tab, then choose Environment variables from the left menu.
  • Click Edit.
  • Click Add environment variable and add each of these key-value pairs (click Add environment variable again for each new row):
  • Key: NVIDIA_API_KEY / Value: your NVIDIA NIM API key.
  • Key: GNEWS_API_KEY / Value: your GNews API key.
  • Key: SNS_TOPIC_ARN / Value: placeholder (you'll update this in the next step when you create the SNS topic).
  • Key: DYNAMODB_TABLE / Value: linkedin-post-history.
  • Click Save.

You should see all four environment variables listed in the Environment variables section.

Next, increase the timeout so the function has enough time to call the NIM API and process the response.

  • In the Configuration tab, choose General configuration from the left menu.
  • Click Edit.
  • Set Timeout to 1 minute 0 seconds (60 seconds total).
  • Click Save.

Why 60 seconds?

The default Lambda timeout is 3 seconds. Your function makes multiple API calls to NVIDIA NIM (scoring headlines, then generating a post) plus a network call to GNews. These can take 10-30 seconds combined. Setting 60 seconds gives comfortable headroom.

Now grant the function permission to read and write to your DynamoDB table.

  • In the Configuration tab, choose Permissions from the left menu.
  • Under Execution role, click the role name link (it will look something like linkedin-post-agent-role-xxxxx). This opens the IAM console in a new tab.
  • In the IAM role page, click Add permissions, then Create inline policy.
  • Click the JSON tab and replace the contents with this policy:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "dynamodb:GetItem",
                "dynamodb:PutItem",
                "dynamodb:Scan"
            ],
            "Resource": "arn:aws:dynamodb:*:*:table/linkedin-post-history"
        }
    ]
}

What does this policy do?

This grants your Lambda function three specific DynamoDB actions on the linkedin-post-history table. Scan reads recent topics. PutItem saves new posts. GetItem allows individual lookups.

The *:* in the resource ARN means it works in any region and account, so you do not need to hard-code your account ID.

  • Click Next.
  • For Policy name, enter linkedin-agent-dynamodb-access.
  • Click Create policy.

You should see the new policy listed under Permissions policies alongside AWSLambdaBasicExecutionRole.

  • Close the IAM tab and return to the Lambda console tab.

Time to test the function. Before you run it, think about what you expect to happen. The function will call GNews for headlines, call NVIDIA NIM to score and generate, then save to DynamoDB.

  • Click the Test tab.
  • For Event name, enter test-run.
  • Leave the Event JSON as the default empty object {}.
  • Click Save, then click Test.

Do you think the function will succeed on the first try, or will something go wrong? Take a guess before looking at the result.

After 10-30 seconds, the execution result should show a green Succeeded banner. Expand the details to see the response body containing a topic and post_preview with a real LinkedIn post draft referencing a trending headline from today.

Seeing a timeout or error?

If you see Task timed out after 60 seconds, the NIM API may be slow to respond. Try clicking Test again. Cold starts on first invocation can be slower.

If you see a KeyError for an environment variable, double-check that all four keys (NVIDIA_API_KEY, GNEWS_API_KEY, SNS_TOPIC_ARN, DYNAMODB_TABLE) are saved in the Environment variables section.

If you see an AccessDeniedException mentioning DynamoDB, verify the inline policy is attached to the correct role and the table name matches linkedin-post-history.

Help me troubleshoot my Lambda function error

Your agent is now running in the cloud. Next up, you'll wire it to send the draft to your inbox every morning via SNS and EventBridge Scheduler.

Wire Up Email Delivery and Daily Schedule

In the previous step, you proved the agent can generate a LinkedIn post and save it to DynamoDB.

The agent runs on demand, but you want it to fire every morning and land the draft in your inbox without logging into AWS. In this step, you will connect Amazon SNS for email delivery and EventBridge Scheduler for daily automation.

In this step, get ready to:
  • Create an SNS topic and email subscription so the agent can send you post drafts.
  • Add email-sending logic to the Lambda function and grant it publishing permissions.
  • Schedule the Lambda to run automatically every morning with EventBridge Scheduler.
Create an SNS topic and email subscription

Amazon SNS (Simple Notification Service) is a messaging service that can push messages to email, SMS, or other endpoints. You will create a topic that acts as a channel, then subscribe your email address to it.

  • In the AWS Management Console, search for SNS in the top search bar and open the Simple Notification Service console.
  • In the left navigation pane, click Topics.
  • Click Create topic.
  • For Type, select Standard.
  • For Name, enter linkedin-post-drafts.
  • Leave all other settings as default and click Create topic.

The topic detail page opens, showing your topic's ARN. Copy this ARN and save it somewhere accessible. You will need it shortly.

  • On the topic detail page, click Create subscription.
  • For Protocol, select Email.
  • For Endpoint, enter your personal email address.
  • Click Create subscription.

What happens next?

AWS sends a confirmation email to the address you entered. The subscription only becomes active after you confirm it. Without confirming, no emails will be delivered.

  • Open your email inbox and look for a message from AWS Notifications.
  • Click the Confirm subscription link inside the email.
  • Return to the SNS console. Your subscription status should now show Confirmed.

Don't see the confirmation email?

  • Check your spam or junk folder.
  • Verify the email address you entered matches your actual inbox.
  • If it still does not appear after a few minutes, delete the subscription and recreate it with the correct address.

Why isn't my SNS subscription confirming?

Add the email-sending function and re-deploy

Your Lambda function can now generate posts, but it has no way to send them to you. You will add a send_email function that publishes the draft to SNS, then update the handler to call it.

  • Open lambda_function.py in your local project folder.
  • Find the save_post function. Add the following new function directly below it:
def send_email(post_text, topic):
    """Send the post draft via SNS email."""
    subject = f"LinkedIn Draft: {topic}"
    sns.publish(
        TopicArn=SNS_TOPIC_ARN,
        Subject=subject[:100],
        Message=f"Your LinkedIn post draft for today:\n\n---\n\n{post_text}\n\n---\n\nTopic: {topic}\nGenerated: {datetime.datetime.now().isoformat()}\n\nIf this looks good, paste it into LinkedIn!"
    )

What does this code do?

  • `sns.publish()` sends a message to all subscribers of the SNS topic ARN stored in your environment variable.
  • `Subject` is truncated to 100 characters because SNS enforces a subject length limit.
  • The Message body contains the full post draft wrapped with separators so it is easy to find in your inbox.

Now you need to update the lambda_handler function to call send_email in two places.

  • Inside lambda_handler, find the if not relevant: block. It currently looks like this:
    if not relevant:
        return {"statusCode": 200, "body": "No relevant new topics."}
  • Add a send_email call before the return so the block now looks like this:
    if not relevant:
        send_email("No relevant trending topics found today that haven't been covered recently.", "No post today")
        return {"statusCode": 200, "body": "No relevant new topics."}
  • Next, find the line save_post(chosen_topic, post_text) near the bottom of lambda_handler. Add the following line directly below it:
    # Send via email
    send_email(post_text, chosen_topic)
  • Save lambda_function.py.

Now re-package and upload the updated code to Lambda.

  • Select all files inside your project folder (not the folder itself) and compress them into a new .zip file.
  • In the Lambda console, open your linkedin-post-agent function.
  • Click the Code tab, then Upload from, then .zip file.
  • Select your new .zip and click Upload, then Save.

Next, point the SNS_TOPIC_ARN environment variable at your real topic.

  • In the Lambda function, click the Configuration tab, then Environment variables.
  • Click Edit.
  • Update the value of SNS_TOPIC_ARN to the topic ARN you copied earlier (it looks like arn:aws:sns:us-east-2:123456789012:linkedin-post-drafts).
  • Click Save.

Finally, grant the Lambda execution role permission to publish to your topic.

  • In the Configuration tab, click Permissions.
  • Under Execution role, click the role name link to open it in the IAM console.
  • In the Permissions policies pane, click Add permissions, then Create inline policy.
  • In the Policy editor, select JSON.
  • Replace the contents with the following policy, substituting your real topic ARN:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "sns:Publish",
      "Resource": "arn:aws:sns:REGION:ACCOUNT_ID:linkedin-post-drafts"
    }
  ]
}
  • Click Next.
  • For Policy name, enter sns-publish-linkedin-drafts.
  • Click Create policy.

What does this policy do?

This inline policy grants the Lambda function permission to call sns:Publish on your specific topic. Without it, the function would get an AccessDenied error when trying to send the email.

Time to test. Before you run this, do you think the function will successfully send you an email?

  • Return to the Lambda console, open your linkedin-post-agent function, and click Test with an empty event.

The function should execute successfully. Within a minute or two, check your email inbox for a message with the subject line starting with "LinkedIn Draft:" followed by the chosen topic.

Function returns AccessDeniedException?

  • Make sure the Resource ARN in your inline policy matches the exact ARN of your linkedin-post-drafts topic.
  • Confirm the SNS_TOPIC_ARN environment variable matches the same ARN.
  • If you just created the policy, wait 10-15 seconds for IAM changes to propagate, then test again.

Why is my Lambda getting an AccessDeniedException when publishing to SNS?

Schedule daily execution with EventBridge Scheduler

The agent now generates and emails you a post on demand. The final piece is making it run automatically every morning so you never have to trigger it manually.

  • In the Lambda console, open your linkedin-post-agent function.
  • Click Add trigger in the function overview diagram.
  • In the trigger source dropdown, select EventBridge (CloudWatch Events).
  • Select Create a new rule.
  • For Rule name, enter daily-linkedin-post.
  • For Rule type, select Schedule expression.
  • In the Schedule expression field, enter:
cron(0 7 * * ? *)

What does this cron expression mean?

The format is cron(minutes hours day-of-month month day-of-week year). This expression fires at 7:00 AM UTC every day. Adjust the hour to match your timezone if you prefer a different delivery time.

  • Click Add.

The trigger is now attached to your function. EventBridge will invoke linkedin-post-agent every day at the scheduled time.

Test without waiting until tomorrow

You already confirmed the full pipeline works using the Test button in the previous substep. The schedule simply fires that same invocation automatically. If you want to verify the schedule itself, temporarily edit the cron to 5 minutes from now, wait for the email, then change it back.

Schedule not triggering?

  • Cron times are in UTC. If you set it to cron(0 7 * * ? *) and you are in UTC-5, the email arrives at 2:00 AM local time.
  • Check that the trigger shows Enabled in the function overview diagram.
  • Review CloudWatch Logs for any errors by opening the Monitor tab in the Lambda console.

Why isn't my EventBridge schedule triggering my Lambda function?

Your LinkedIn post agent is now fully automated. Every morning, it fetches news, filters for relevance, generates a draft in your voice, checks it against post history, and emails you the result. Next up, you will add a quality gate that catches hallucination before the draft reaches your inbox.

Secret mission

Add a Content Quality Gate

Your agent generates posts, but what if it hallucinates credentials you do not have? What if it drifts off-topic or violates your style rules? In this mission, you add a self-evaluating quality gate that catches bad drafts before they reach your inbox.

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. All resources in this project operate within the AWS Always-Free tier, so there are no ongoing costs as long as usage stays within the free allowances.

Resources you used:

  • DynamoDB table linkedin-post-history (free tier: 25 GB storage, 25 read/write capacity units).
  • Lambda function linkedin-post-agent (free tier: 1M requests/month, 400,000 GB-seconds).
  • EventBridge Scheduler schedule daily-linkedin-post (free tier: 14M invocations/month).
  • SNS topic linkedin-post-drafts with email subscription (free tier: 1M publishes/month, 1,000 email deliveries).
  • IAM role linkedin-post-agent-role (no cost).
  • Local project folder on your Desktop containing lambda_function.py, cv.md, and humanizer.md.

Keep everything running

No action needed. Choose this if you want the agent to keep running daily and delivering LinkedIn post drafts to your inbox. All services are within the AWS Always-Free tier, so you will not be charged.

  • The EventBridge schedule fires once per day.
  • DynamoDB stores one record per day (well under 25 GB).
  • SNS sends one email per day (well under 1,000/month).

Pause - I'll come back to this later

Disable the daily schedule so the agent stops running, but keep all resources intact for when you return.

  • Open the EventBridge Scheduler console at https://console.aws.amazon.com/scheduler/.
  • Select your daily-linkedin-post schedule, then choose Edit.
  • In the left navigation, choose Settings.
  • Toggle Enable schedule to off, then save your changes.

Everything else stays in place. When you are ready to resume, toggle the schedule back on.

Delete - I don't want to use this again

Remove all project resources so nothing remains in your AWS account or on your machine.

  • Delete the EventBridge schedule: open the EventBridge Scheduler console, select daily-linkedin-post, choose Delete, and confirm.
  • Delete the Lambda function: open the Lambda console Functions page, select linkedin-post-agent, choose Actions > Delete, type confirm, and choose Delete.
  • Delete the DynamoDB table: open the DynamoDB console, choose Tables in the left pane, select the checkbox beside linkedin-post-history, choose Delete, enter delete, and choose Delete table.
  • Delete the SNS topic: open the SNS console, choose Topics in the left pane, select linkedin-post-drafts, choose Delete, enter delete me, and choose Delete.
  • Delete the IAM execution role: open the IAM console Roles page, select linkedin-post-agent-role, choose Delete, enter the role name to confirm, and choose Delete.
  • Delete local files: open your terminal and remove your project folder from the Desktop.

macOS

cd ~/Desktop
rm -rf linkedin-post-agent

Windows

cd ~\Desktop
Remove-Item -Recurse -Force linkedin-post-agent

After completing these steps, no project resources remain in your AWS account or on your machine.

That's a Wrap!

That's a Wrap!

Nice work! You just built and deployed a fully automated AI content agent that wakes up every morning, researches trending news in your field, and delivers a personalized LinkedIn post draft to your inbox.

You've learned how to:

  • Build an AI agent that uses NVIDIA NIM (Kimi K2.6) with domain relevance scoring and content generation to turn raw news into professional posts grounded in your CV.
  • Deploy a serverless pipeline on AWS Lambda with a .zip package upload, wire DynamoDB as a memory layer for post deduplication, and schedule daily runs with EventBridge Scheduler and SNS email delivery.
  • Implement content guardrails including CV-based relevance filtering, topic deduplication over a 14-day window, and a humanizer tone file that keeps the agent writing in your voice.
  • Secret Mission: Added a self-evaluating quality gate that catches hallucination and off-topic drift before posts reach your inbox, with retry logic across multiple candidates.

Ready to quiz yourself?