Daily LinkedIn Post Agent
Build a LangGraph AI agent that writes LinkedIn posts from live news.
Introduction
30 Second Summary
Posting on LinkedIn consistently builds professional authority. But coming up with fresh, relevant content every day takes time you could spend on actual work.
In this project, you will build an AI agent that reads the latest news in your domain, drafts a LinkedIn post in your professional voice, and delivers it straight to your email inbox.
What You'll Build
You run one command and a polished LinkedIn post lands in your email within seconds, referencing a real news story from today and written in your professional voice.
By the end of this project, you'll have:
- Run a single command and receive a fresh LinkedIn post in your email, written by a multi-node LangGraph agent about a real news story from your professional domain.
- Run it again tomorrow and get a completely different topic. DynamoDB-backed memory ensures your content never repeats a subject you have already covered.
- Show the output to a colleague and watch them assume you wrote it. Two grounding files (your CV and a writing style guide) teach the agent your voice, expertise, and style.
- Secret Mission: Add a relevance scoring node that ranks articles by alignment with your CV and picks the most relevant story to write about.
Are there any prerequisites?
You'll need Python 3.11 installed, an AWS account with the CLI configured, and an NVIDIA NIM API key. The project walks you through getting a free GNews API key (no credit card required) during Step 1.
Before We Start
Your LinkedIn agent needs to pull live news, call an LLM, check a database, and send emails. Each of those actions depends on a different external service with its own API key and Python package.
In this step you will set up the project folder, install every dependency, and configure all the credentials so that the rest of the build can focus purely on agent logic.
In this step, get ready to:
- Create your project folder, virtual environment, and install Python packages.
- Get a free GNews API key and configure your environment variables.
- Create your professional context files that ground the agent's writing.
Create project directory and virtual environment
Every Python project starts with an isolated environment so your packages never collide with other projects on your machine.
- 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
- Confirm the folder exists by running:
ls ~/Desktop | grep linkedin
You should see linkedin-post-agent in the output.
- Create and activate a virtual environment by running these commands:
macOS
python3 -m venv venv
source venv/bin/activate
Windows
python -m venv venv
venv\Scripts\activate
Your terminal prompt now shows (venv) at the start, confirming the virtual environment is active.
Don't see (venv) in your prompt?
Make sure you ran the source command (macOS) or venv\Scripts\activate (Windows) in the same terminal where you created the virtual environment.
If you closed and reopened the terminal, re-run the activate command from inside the linkedin-post-agent folder.
- Create a file called requirements.txt in your linkedin-post-agent folder and paste this content:
langgraph==1.2.10
langchain-openai==1.4.1
langchain-core>=1.4.7,<2
boto3>=1.35.0
requests>=2.31.0
python-dotenv>=1.0.0
What are these packages?
- langgraph orchestrates your agent as a state graph with sequential nodes.
- langchain-openai provides the ChatOpenAI class that talks to any OpenAI-compatible endpoint (including NVIDIA NIM).
- boto3 is the AWS SDK for Python, used for DynamoDB and SNS.
- requests handles HTTP calls to the GNews API.
- python-dotenv loads your .env file so secrets stay out of your code.
- Install all dependencies by running:
pip install -r requirements.txt
You will see pip downloading and installing each package. The final line reads Successfully installed followed by the package names.
- Verify that LangGraph installed correctly by running:
python -c "from langgraph.graph import StateGraph; print('LangGraph ready')"
You should see LangGraph ready printed to your terminal.
Seeing ModuleNotFoundError?
Make sure your virtual environment is active (you see (venv) in your prompt). If not, re-run the activate command from inside your linkedin-post-agent folder, then run pip install -r requirements.txt again.
help me fix my LangGraph import error
Get your GNews API key and configure environment variables
The agent fetches real news articles through the GNews API. You need a free API key to authenticate your requests.
- Open https://gnews.io/register in your browser.
- Enter your email and a password, then click Sign up.
- Verify your email address by clicking the confirmation link in your inbox.
- Once signed in, your API key is displayed on the dashboard. Copy it.
What is GNews?
GNews is a REST API that aggregates news from over 80,000 sources worldwide. The free plan gives you 100 requests per day, which is more than enough for development and testing.
- Create a file called .env in your linkedin-post-agent folder and paste the following content:
NVIDIA_API_KEY=your-nvidia-api-key-here
GNEWS_API_KEY=your-gnews-api-key-here
AWS_DEFAULT_REGION=us-east-1
SNS_TOPIC_ARN=your-sns-topic-arn-here
- Replace your-nvidia-api-key-here with your NVIDIA NIM API key (starts with nvapi-).
- Replace your-gnews-api-key-here with the key you just copied from the GNews dashboard.
- Leave SNS_TOPIC_ARN as the placeholder for now. You will fill it in when you create the SNS topic in a later step.
Why a .env file?
Storing credentials in a .env file keeps them out of your source code. The python-dotenv package loads these values as environment variables at runtime so your agent can read them without hardcoding secrets.
- Verify your environment loads correctly by running:
python -c "from dotenv import load_dotenv; import os; load_dotenv(); print('NVIDIA key starts with:', os.environ.get('NVIDIA_API_KEY','')[:6])"
You should see NVIDIA key starts with: nvapi- confirming the .env file loads correctly.
Seeing an empty string or None?
Make sure .env is in the same folder where you ran the command (your linkedin-post-agent folder). Check that there are no spaces around the = sign in your key entries.
help me debug my .env file loading
Create your grounding files
The agent uses two context files to ground its output. Your CV tells it what domain expertise to draw from. The humanizer file tells it how to write like you, not a robot.
- Create a file called cv.md in your linkedin-post-agent folder and paste this template:
# Professional Background
## Role
[Your current role, e.g., Senior Cloud Engineer at Acme Corp]
## Domain Expertise
- [Key skill 1, e.g., AWS serverless architecture]
- [Key skill 2, e.g., AI/ML pipeline development]
- [Key skill 3, e.g., DevOps automation]
## Industry Focus
[Your industry, e.g., FinTech, HealthTech, SaaS]
## Key Achievements
- [Achievement 1]
- [Achievement 2]
## Topics I Post About
- [Topic 1]
- [Topic 2]
- [Topic 3]
- Replace each bracketed placeholder with your own professional details. The more specific you are, the more tailored your generated posts will be.
What does the agent do with cv.md?
The LLM reads this file as context before generating each post. It uses your role, skills, and topic preferences to write posts that sound like they come from someone with your exact expertise.
- Create a file called humanizer.md in your linkedin-post-agent folder and paste this content:
# Writing Style Guide for LinkedIn Posts
## Tone
- Conversational but professional
- First person, as if talking to a colleague
- Confident without being arrogant
## Structure
- Open with a hook (surprising stat, bold claim, or question)
- Keep paragraphs to 1-2 sentences for mobile readability
- End with a question or call-to-action to drive engagement
## Rules
- Never use: "In today's fast-paced world", "Let's dive in", "Game-changer", "Leverage", "Synergy"
- No emojis in the first line
- Maximum 150 words total
- Include one specific detail or number
- Reference the news source naturally
## Format
- Use line breaks between paragraphs
- No bullet points (save for comments)
- No hashtags in the body (add 3-5 at the very end, separated by a blank line)
What does humanizer.md do?
This file acts as a style guide the LLM must follow when writing. It prevents generic AI slop by banning overused phrases and enforcing a tight structure. The result reads like something you actually wrote.
- Verify both files load correctly by running:
python -c "from pathlib import Path; print('cv.md:', len(Path('cv.md').read_text()), 'chars'); print('humanizer.md:', len(Path('humanizer.md').read_text()), 'chars')"
You should see two lines showing the character count for each file. Both should be greater than zero.
Seeing FileNotFoundError?
Make sure you are running the command from inside your linkedin-post-agent folder. Run ls to confirm cv.md and humanizer.md are listed in the current directory.
help me find my grounding files
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files: requirements.txt, .env, cv.md, and humanizer.md.
ⓧ I'd like to double check the full code
Scroll up and confirm each of the four files is present in your linkedin-post-agent folder with the content shown above:
- requirements.txt with 6 pinned dependencies.
- .env with 4 environment variable lines.
- cv.md with your professional background.
- humanizer.md with the writing style guide.
Run ls in your terminal to confirm all four files appear.
Your workspace is fully configured with all dependencies, credentials, and context files. Next up, you will build the agent's first two nodes and see a generated LinkedIn post in your terminal.
Build the News Fetcher and Generate Your First Post
Your environment is set up and your grounding files are ready. Now it's time to build the core of your agent: a LangGraph state machine that fetches real news and generates a LinkedIn post from it.
The fastest path to a visible result is connecting your APIs and seeing a real post come out the other end. This gives you a working baby version of the agent that validates every external connection before you add complexity.
In this step, get ready to:
- Fetch trending news articles from GNews API.
- Generate a LinkedIn post using NVIDIA NIM.
- Wire the graph and run it end-to-end.
Build a minimal LangGraph with two nodes
A LangGraph agent is a state graph. Each node is a Python function that receives the current state, does work, and returns updates. The state flows from node to node along edges you define.
- Create a file called agent.py in your linkedin-post-agent folder by running:
touch agent.py
- Open agent.py in your editor and paste the following imports, state definition, and grounding helper:
import os
import json
import requests
import boto3
from typing import TypedDict
from pathlib import Path
from dotenv import load_dotenv
from langgraph.graph import START, StateGraph
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage
load_dotenv()
class State(TypedDict):
articles: list
selected_article: dict
generated_post: str
grounding_context: str
def load_grounding_files() -> str:
cv = Path("cv.md").read_text(encoding="utf-8")
humanizer = Path("humanizer.md").read_text(encoding="utf-8")
return f"--- PROFESSIONAL BACKGROUND ---\n{cv}\n\n--- WRITING STYLE GUIDE ---\n{humanizer}"
What does this code do?
- load_dotenv() reads your .env file and loads the API keys into environment variables.
- State is a TypedDict that defines what data flows between nodes: fetched articles, the selected article, the generated post text, and grounding context.
- load_grounding_files() reads your cv.md and humanizer.md into one string the LLM can use as context.
- Save agent.py and verify the imports resolve by running:
python agent.py
You should see no output and no errors. A clean exit means all packages imported successfully.
Seeing an ImportError?
- Make sure your virtual environment is activated. You should see (venv) or similar in your terminal prompt.
- If you see a module not found error for langgraph or langchain_openai, re-run pip install -r requirements.txt inside your activated environment.
Why isn't my import working?
- Now add the fetch_news function below load_grounding_files in agent.py:
def fetch_news(state: State) -> dict:
api_key = os.environ["GNEWS_API_KEY"]
query = os.environ.get("SEARCH_QUERY", "artificial intelligence cloud computing")
url = "https://gnews.io/api/v4/search"
params = {
"q": query,
"lang": "en",
"max": 5,
"apikey": api_key,
}
response = requests.get(url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
articles = data.get("articles", [])
print(f"Fetched {len(articles)} articles from GNews")
return {"articles": articles}
What does this code do?
- This is your first LangGraph node. It receives the current state and returns a dictionary of state updates.
- It calls the GNews search endpoint with your domain keywords and returns up to 5 articles.
- The SEARCH_QUERY env var lets you override the default keywords later without editing code.
- Save agent.py and test that news fetching works by running:
python -c "from agent import fetch_news; fetch_news({'articles':[], 'selected_article':{}, 'generated_post':'', 'grounding_context':''})"
You should see output like Fetched 5 articles from GNews. This confirms your API key is valid and the endpoint is reachable.
Seeing a 401 or 403 error from GNews?
- A 401 means your API key is missing or invalid. Double-check the GNEWS_API_KEY value in your .env file matches the key shown in your GNews dashboard.
- A 403 means you have hit your daily quota (100 requests/day on the free plan). Your quota resets at midnight UTC.
- Make sure there are no extra spaces or quotes around the key value in .env.
Help me debug my GNews API connection error.
- Next, add the generate_post function below fetch_news in agent.py. This is a longer function, so paste it in two parts. First, add the function header and LLM setup:
def generate_post(state: State) -> dict:
selected = state.get("selected_article", {})
if not selected:
selected = state["articles"][0] if state["articles"] else {}
if not selected:
return {"generated_post": "No new topics available today."}
grounding = load_grounding_files()
llm = ChatOpenAI(
base_url="https://integrate.api.nvidia.com/v1",
api_key=os.environ["NVIDIA_API_KEY"],
model="meta/llama-3.1-70b-instruct",
temperature=0.7,
max_tokens=512,
)
What does this code do?
- The function picks the first article from the fetched list (since we don't have deduplication yet).
- ChatOpenAI connects to NVIDIA NIM using the OpenAI-compatible endpoint at https://integrate.api.nvidia.com/v1. This works because NIM exposes the same API shape as OpenAI.
- The model meta/llama-3.1-70b-instruct is a powerful instruction-following model available on the free tier.
- Immediately below the LLM setup (still inside generate_post), add the prompt messages and response handling:
messages = [
SystemMessage(content=f"""You are a LinkedIn post ghostwriter.
Use the following context to write in the author's voice and domain expertise.
{grounding}"""),
HumanMessage(content=f"""Write a LinkedIn post about this news article:
Title: {selected.get('title', '')}
Description: {selected.get('description', '')}
Source: {selected.get('source', {}).get('name', '')}
URL: {selected.get('url', '')}
Follow the writing style guide exactly. The post must be grounded in the author's professional background."""),
]
response = llm.invoke(messages)
post_text = response.content
print(f"\n--- Generated Post ---\n{post_text}\n")
return {"generated_post": post_text}
What does this code do?
- The SystemMessage injects your CV and writing style guide so the LLM writes in your voice.
- The HumanMessage passes the article details and asks the LLM to generate a post following your style rules.
- The generated post is printed to the terminal and returned as a state update.
- Save agent.py and confirm there are no syntax errors by running:
python agent.py
Again, no output and no errors means the function definitions are valid Python.
Wire the graph and run
You have two nodes defined but nothing connecting them. LangGraph needs you to register each node, draw edges between them, and compile the graph into a runnable app.
- At the bottom of agent.py, add the graph wiring and main entry point:
graph = StateGraph(State)
graph.add_node("fetch_news", fetch_news)
graph.add_node("generate_post", generate_post)
graph.add_edge(START, "fetch_news")
graph.add_edge("fetch_news", "generate_post")
app = graph.compile()
if __name__ == "__main__":
result = app.invoke(
{
"articles": [],
"selected_article": {},
"generated_post": "",
"grounding_context": "",
}
)
print("\nDone.")
What does this code do?
- StateGraph(State) creates a new graph that tracks your State TypedDict.
- add_node registers each function as a named node. add_edge draws the execution path: START to fetch_news, then fetch_news to generate_post.
- app.invoke() runs the full graph with an initial empty state. Each node fills in its piece as the state flows through.
- Save agent.py.
Before you run this, what do you think will happen? The agent will call GNews, pick the first article, send it to NVIDIA NIM with your grounding files, and print the result. Do you expect a polished LinkedIn post or an error?
- Run the full agent by executing:
python agent.py
You should see output like this:
Fetched 5 articles from GNews
--- Generated Post ---
[A LinkedIn post about a real news article, written in your professional voice]
Done.
You just ran your first AI agent. It pulled real news from the internet, combined it with your professional background, and produced a post you could paste straight to LinkedIn.
Getting a 403 from NVIDIA NIM?
- Your API key may be missing the Public API Endpoints permission. Go to build.nvidia.com, sign in, pick any model, and click "Get API Key" from that page. This flow auto-provisions the required permission on your account.
- If you generated your key directly from the NGC Personal Keys page, it may be missing this scope. Generate a fresh key through the build.nvidia.com flow instead.
- Check that NVIDIA_API_KEY in your .env starts with nvapi- and has no trailing spaces.
Help me fix my NVIDIA NIM 403 error.
✔️ Awesome, I've got everything!
Great. Double check you have saved agent.py and that running it produces a generated post with no errors.
ⓧ I'd like to double check the full code
Scroll up through your agent.py and confirm these sections are present, in this order:
- Imports (10 import lines) followed by load_dotenv().
- State TypedDict class with four fields.
- load_grounding_files() function that reads cv.md and humanizer.md.
- fetch_news() function calling the GNews search endpoint.
- generate_post() function calling NVIDIA NIM with grounding context.
- Graph wiring: StateGraph, two add_node calls, two add_edge calls, app = graph.compile().
- if __name__ == "__main__": block that invokes the graph and prints "Done."
Your two-node agent works. Next up, you will add DynamoDB to track which articles you have already posted about so the agent never repeats itself.
Add DynamoDB for Post History and Deduplication
Your agent generates a LinkedIn post from today's news. That works great once.
But what happens if you run it again tomorrow and the same article is still trending? You get a duplicate post. You need persistent memory so the agent knows what it has already written about and skips those topics.
In this step, get ready to:
- Create a DynamoDB table to store post history.
- Build helper functions for duplicate checking and post storage.
- Add a deduplication node to your LangGraph agent.
Create the DynamoDB table
Your agent needs somewhere to record which articles it has already posted about. A DynamoDB table with the article URL as the key gives you a fast yes/no lookup for any article.
- Create the linkedin-posts table by running this command in your terminal:
aws dynamodb create-table --table-name linkedin-posts --attribute-definitions AttributeName=article_url,AttributeType=S --key-schema AttributeName=article_url,KeyType=HASH --billing-mode PAY_PER_REQUEST
What does this command do?
- --table-name linkedin-posts names the table your agent will query.
- --attribute-definitions declares article_url as a String attribute.
- --key-schema sets article_url as the partition key. This means each article URL is unique in the table.
- --billing-mode PAY_PER_REQUEST means you only pay for reads and writes you actually make. Within the free tier, this costs nothing.
- Confirm the table was created by running:
aws dynamodb describe-table --table-name linkedin-posts --query "Table.TableStatus"
You should see "ACTIVE" printed. This means the table is ready for reads and writes.
Seeing 'ResourceNotFoundException'?
The table takes a few seconds to finish creating. Wait 5-10 seconds and run the describe command again.
If you see a credentials error, confirm your AWS CLI is configured for us-east-1 by checking the AWS_DEFAULT_REGION value in your .env file matches what your CLI uses.
Help me debug my DynamoDB table creation error.
Build the DynamoDB helper functions
You need two operations on this table: checking whether an article URL already exists, and storing a new post. A dedicated helper module keeps your agent code clean.
- Create a new file called dynamo_helpers.py in your linkedin-post-agent folder and paste the following code:
import boto3
from datetime import datetime, timezone
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table("linkedin-posts")
def is_duplicate(article_url: str) -> bool:
response = table.get_item(Key={"article_url": article_url})
return "Item" in response
def store_post(article_url: str, title: str, post_text: str) -> None:
table.put_item(
Item={
"article_url": article_url,
"title": title,
"post_text": post_text,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
What does this code do?
- is_duplicate() calls get_item on the table with the article URL. If DynamoDB finds a matching item, the article was already used.
- store_post() writes a new item with the article URL, title, generated text, and a UTC timestamp.
- The table connection is created once at module level so every function reuses the same reference.
- Save dynamo_helpers.py.
- Verify the helpers can connect to your table by running:
python -c "from dynamo_helpers import is_duplicate; print(is_duplicate('https://test.com'))"
You should see False printed. The table is empty, so no URL is a duplicate yet.
Seeing a ModuleNotFoundError?
Make sure your virtual environment is activated and that you are running the command from inside the linkedin-post-agent folder where dynamo_helpers.py lives.
Help me fix my dynamo_helpers import error.
Insert the filter_duplicates node into the graph
Now you need a new node in your LangGraph that sits between fetch_news and generate_post. This node loops through the fetched articles, checks each one against DynamoDB, and removes any you have already covered.
- In agent.py, find the last import line at the top of the file:
from langchain_core.messages import SystemMessage, HumanMessage
- Add this import directly below it:
from dynamo_helpers import is_duplicate, store_post
- Next, find the generate_post function. Add the following filter_duplicates function directly above it:
def filter_duplicates(state: State) -> dict:
articles = state["articles"]
unique_articles = []
for article in articles:
if not is_duplicate(article["url"]):
unique_articles.append(article)
else:
print(f"Skipping duplicate: {article['title']}")
if not unique_articles:
print("No new articles found. All topics already covered.")
return {"articles": [], "selected_article": {}}
selected = unique_articles[0]
print(f"Selected article: {selected['title']}")
return {"articles": unique_articles, "selected_article": selected}
What does this node do?
- It loops through every article from fetch_news and calls is_duplicate() on each URL.
- Articles already in DynamoDB get skipped. Articles not in the table pass through.
- The first unique article is set as selected_article in the state, so generate_post knows exactly which article to write about.
Since filter_duplicates now handles selecting the article, you need to simplify generate_post so it no longer guesses which article to use.
- In the generate_post function, find these two lines near the top:
selected = state.get("selected_article", {})
if not selected:
selected = state["articles"][0] if state["articles"] else {}
if not selected:
- Replace them with this simpler version that trusts filter_duplicates to have already chosen an article:
selected = state.get("selected_article", {})
if not selected:
- Finally, scroll to the bottom of agent.py where the graph is built. Find this section:
graph = StateGraph(State)
graph.add_node("fetch_news", fetch_news)
graph.add_node("generate_post", generate_post)
graph.add_edge(START, "fetch_news")
graph.add_edge("fetch_news", "generate_post")
app = graph.compile()
- Replace it with this updated graph that routes through the new filter node:
graph = StateGraph(State)
graph.add_node("fetch_news", fetch_news)
graph.add_node("filter_duplicates", filter_duplicates)
graph.add_node("generate_post", generate_post)
graph.add_edge(START, "fetch_news")
graph.add_edge("fetch_news", "filter_duplicates")
graph.add_edge("filter_duplicates", "generate_post")
app = graph.compile()
What changed in the graph?
The agent now flows through three nodes: START -> fetch_news -> filter_duplicates -> generate_post. The filter sits between fetching and generating, acting as a gatekeeper that only lets fresh topics through.
- Save agent.py.
- Run the agent to confirm the new node executes:
python agent.py
You should see Fetched 5 articles from GNews followed by Selected article: ... and then a generated post. The filter node is working.
Seeing an ImportError for dynamo_helpers?
Confirm dynamo_helpers.py is in the same folder as agent.py. Both files must be at the top level of your linkedin-post-agent directory.
Help me fix the import error in my LangGraph agent.
Store generated posts and verify deduplication
The filter checks DynamoDB, but nothing writes to DynamoDB yet. You need to store each generated post so that the next run knows to skip that article.
- In agent.py, find the end of the generate_post function. Locate these lines:
post_text = response.content
print(f"\n--- Generated Post ---\n{post_text}\n")
return {"generated_post": post_text}
- Replace them with this version that stores the post before returning:
post_text = response.content
print(f"\n--- Generated Post ---\n{post_text}\n")
store_post(
article_url=selected.get("url", "unknown"),
title=selected.get("title", "unknown"),
post_text=post_text,
)
return {"generated_post": post_text}
What does this change do?
After the LLM generates the post, store_post() writes the article URL, title, and generated text to DynamoDB. On the next run, filter_duplicates will find this URL and skip the article.
- Save agent.py.
Now for the real test. Before you run the agent, think about what should happen if you run it twice in a row with the same news cycle. What do you expect the second run to do differently?
- Run the agent for the first time:
python agent.py
You should see a generated post and the output Done. at the end. The article URL is now stored in DynamoDB.
- Run the agent a second time immediately:
python agent.py
This time you should see Skipping duplicate: ... for the article you just posted about. The agent picks a different article or prints No new articles found. All topics already covered. if all five results were already used.
Second run still picking the same article?
Open your DynamoDB table in the AWS console and check if any items were written after the first run. If the table is empty, confirm the store_post() call is inside generate_post before the return statement.
Also verify your AWS_DEFAULT_REGION in .env matches the region where you created the table.
Help me debug why my agent isn't deduplicating articles.
✔️ Awesome, I've got everything!
Great. Double check you have saved both dynamo_helpers.py and agent.py.
ⓧ I'd like to double check the full code
Your project now has two Python files. Scroll up and confirm each chunk is present in order.
dynamo_helpers.py should contain:
- Imports for boto3 and datetime.
- A module-level table connection to linkedin-posts.
- is_duplicate() function.
- store_post() function.
agent.py should contain:
- The new from dynamo_helpers import is_duplicate, store_post import line.
- The filter_duplicates function between fetch_news and generate_post.
- generate_post with the simplified selected check (no fallback to state["articles"][0]).
- A store_post() call at the end of generate_post before the return.
- The graph with three nodes and edges: START -> fetch_news -> filter_duplicates -> generate_post.
Your agent now has memory. It never repeats a topic. Next up, you will deliver the generated post straight to your email inbox using AWS SNS.
Deliver the Post to Your Inbox via SNS
Your agent generates a polished LinkedIn post, but it just prints to the terminal. That means you still have to open a code editor and run the script every time you want content.
In this step, you will wire up AWS SNS to deliver the finished post straight to your email inbox. One run of the agent and the post lands in your email, ready to copy-paste to LinkedIn.
In this step, get ready to:
- Create an SNS topic and subscribe your email address.
- Add a notify node that delivers the post via email.
- Run the full pipeline and receive the post in your inbox.
Create SNS topic and email subscription
SNS (Simple Notification Service) is a pub/sub messaging service. You publish a message to a topic, and every subscriber on that topic receives it. You will create a topic and subscribe your email address so the agent can push posts to your inbox.
- Create the SNS topic by running this command:
aws sns create-topic --name linkedin-post-delivery
You should see a JSON response containing a TopicArn value like arn:aws:sns:us-east-1:123456789012:linkedin-post-delivery.
- Copy the full TopicArn value from the output. You will need it in a moment.
- Subscribe your email address to the topic by running this command:
aws sns subscribe \
--topic-arn [[SNS_TOPIC_ARN="your TopicArn from the previous command"]] \
--protocol email \
--notification-endpoint [[YOUR_EMAIL="your-email@example.com"]]
You should see "SubscriptionArn": "pending confirmation" in the output.
What does this command do?
The --protocol email flag tells SNS to deliver messages via SMTP (standard email). The --notification-endpoint flag specifies which email address receives the messages.
AWS sends a confirmation email to your address before activating the subscription. This prevents anyone from subscribing someone else's email without their consent.
- Open your email inbox and look for an email from AWS Notifications.
- Click the Confirm subscription link in the email.
- You should see a page that says Subscription confirmed! in your browser.
Don't see the confirmation email?
- Check your spam or junk folder.
- Make sure you typed your email address correctly in the subscribe command. Run the subscribe command again with the correct address if needed.
- Confirm your AWS CLI region matches us-east-1 by running aws configure get region.
Still stuck? Help me confirm my SNS email subscription.
- Now update your .env file. Replace your-sns-topic-arn-here with the actual TopicArn you copied earlier.
Where do I find my TopicArn?
It was printed when you ran aws sns create-topic. If you lost it, run this to list your topics:
aws sns list-topics
Look for the ARN ending in linkedin-post-delivery.
Add the notify node and complete the pipeline
With SNS ready to deliver emails, you need a new graph node that publishes the generated post to your topic. You will also move the store_post() call into this new node so that saving and sending happen together as the final action.
- In agent.py, find the generate_post function. Locate these lines at the end of the function, just before return {"generated_post": post_text}:
store_post(
article_url=selected.get("url", "unknown"),
title=selected.get("title", "unknown"),
post_text=post_text,
)
- Delete those lines. The generate_post function should now end with:
response = llm.invoke(messages)
post_text = response.content
print(f"\n--- Generated Post ---\n{post_text}\n")
return {"generated_post": post_text}
- Next, update the fetch_news function. Find this line at the top of the function:
def fetch_news(state: State) -> dict:
api_key = os.environ["GNEWS_API_KEY"]
query = os.environ.get("SEARCH_QUERY", "artificial intelligence cloud computing")
- Replace it with the following, which reads your CV to inform the news search:
def fetch_news(state: State) -> dict:
api_key = os.environ["GNEWS_API_KEY"]
cv_text = Path("cv.md").read_text(encoding="utf-8")
topics_section = ""
for line in cv_text.split("\n"):
if line.strip().startswith("- ") and topics_section:
topics_section += " " + line.strip("- ").strip()
if "Topics I Post About" in line:
topics_section = "start"
query = os.environ.get("SEARCH_QUERY", "artificial intelligence cloud computing")
What does this change do?
The updated fetch_news now reads your cv.md and parses the Topics I Post About section. This gives the function access to your stated interests for future enhancements like dynamic query building.
- Now add the notify function. Paste this code directly below the generate_post function (after its return statement):
def notify(state: State) -> dict:
post_text = state.get("generated_post", "")
selected = state.get("selected_article", {})
if not post_text or post_text == "No new topics available today.":
print("Nothing to send.")
return state
# Save to DynamoDB before sending
store_post(
article_url=selected.get("url", "unknown"),
title=selected.get("title", "unknown"),
post_text=post_text,
)
What does this code do?
The notify node first checks if there is a valid post to send. If there is, it saves the post to DynamoDB via store_post() before sending. This guarantees the post is recorded for deduplication regardless of whether email delivery succeeds.
- Add the SNS publishing code directly below the store_post() call you just pasted, completing the notify function:
# Publish to SNS for email delivery
sns = boto3.client("sns")
topic_arn = os.environ["SNS_TOPIC_ARN"]
sns.publish(
TopicArn=topic_arn,
Subject="Your LinkedIn Post is Ready",
Message=f"Here's your LinkedIn post for today:\n\n{post_text}\n\n---\nSource: {selected.get('url', '')}",
)
print("Post delivered to your inbox via SNS.")
return state
What does this code do?
This creates an SNS client via boto3 and calls sns.publish() with your topic ARN. The Subject becomes the email subject line. The Message becomes the email body, containing the full post text and a link back to the source article.
- Finally, update the graph wiring at the bottom of agent.py. Find this section:
graph = StateGraph(State)
graph.add_node("fetch_news", fetch_news)
graph.add_node("filter_duplicates", filter_duplicates)
graph.add_node("generate_post", generate_post)
graph.add_edge(START, "fetch_news")
graph.add_edge("fetch_news", "filter_duplicates")
graph.add_edge("filter_duplicates", "generate_post")
- Replace it with this updated version that registers the notify node and connects it as the final step:
graph = StateGraph(State)
graph.add_node("fetch_news", fetch_news)
graph.add_node("filter_duplicates", filter_duplicates)
graph.add_node("generate_post", generate_post)
graph.add_node("notify", notify)
graph.add_edge(START, "fetch_news")
graph.add_edge("fetch_news", "filter_duplicates")
graph.add_edge("filter_duplicates", "generate_post")
graph.add_edge("generate_post", "notify")
What changed in the graph?
You added graph.add_node("notify", notify) to register the new node. Then graph.add_edge("generate_post", "notify") connects it as the final step in the pipeline. The flow is now: START -> fetch_news -> filter_duplicates -> generate_post -> notify -> END.
- Save agent.py.
✔️ Awesome, I've got everything!
Great. Make sure you have saved agent.py and that your .env has the real SNS_TOPIC_ARN value.
ⓧ I'd like to double check the full code
Your agent.py is now long. Scroll through and confirm you have these sections in order:
- Imports and load_dotenv() at the top.
- The State TypedDict class.
- The load_grounding_files() helper.
- The fetch_news function (with cv_text parsing at the top).
- The filter_duplicates function.
- The generate_post function (no store_post call inside it).
- The notify function (with both store_post and sns.publish).
- Graph wiring with four nodes: fetch_news, filter_duplicates, generate_post, notify.
- The if __name__ == "__main__": block at the bottom.
Before you run the pipeline, what do you expect to happen? Think about the full chain: news fetched, duplicates filtered, post generated, then what?
- Run the full pipeline by executing:
python agent.py
You should see the familiar output: articles fetched, one selected, a LinkedIn post generated. Then at the end, you should see Post delivered to your inbox via SNS. printed in your terminal.
- Check your email inbox. Within 60 seconds, you should receive an email with the subject Your LinkedIn Post is Ready containing your generated LinkedIn post.
Not receiving the email?
- Verify your SNS subscription is confirmed by running aws sns list-subscriptions and checking the status is not PendingConfirmation.
- Make sure the SNS_TOPIC_ARN in your .env matches the ARN from aws sns create-topic exactly.
- Check your spam folder. AWS SNS emails sometimes land there on first delivery.
Still stuck? Help me troubleshoot my SNS email delivery.
Your agent now runs end-to-end. It fetches news, filters duplicates, generates a post, saves it to DynamoDB, and delivers it to your inbox. Next up, you will supercharge the article selection with a relevance scoring node in the Secret Mission.
Secret mission
Add Article Relevance Scoring
Your agent picks the first unique article it finds. But not all news in your domain is equally relevant to your expertise. Can you add a scoring node that ranks each article against your CV and picks the best one to write about?
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 AWS DynamoDB and SNS within their free tier limits, so there are no ongoing costs as long as you stay within those allowances.
Resources you used:
- DynamoDB table linkedin-posts (free tier: 25 GB storage, 25 read/write capacity units).
- SNS topic linkedin-post-delivery with email subscription (free tier: 1,000 email notifications/month).
- Local project folder linkedin-post-agent with Python virtual environment, source files, and grounding documents.
Keep everything running
No action needed. Choose this if you plan to keep running the agent regularly.
- DynamoDB and SNS cost nothing within free tier limits. The agent runs on-demand locally whenever you invoke it.
- Your .env file contains your API keys. Keep it safe and never commit it to version control.
- You can re-run python agent.py at any time to generate a fresh LinkedIn post.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your AWS resources incur no cost when idle within free tier limits.
- Deactivate your virtual environment by running deactivate in your terminal.
- Your DynamoDB table and SNS topic remain in place at no cost. They will be ready when you return.
- Your local project files stay on your Desktop. No changes needed.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is irreversible for the DynamoDB post history.
- Delete the DynamoDB table by running this command:
aws dynamodb delete-table --table-name linkedin-posts
- Delete the SNS topic by running this command:
aws sns delete-topic --topic-arn [[SNS_TOPIC_ARN="your-sns-topic-arn-here"]]
- Remove your local project folder. On macOS/Linux, run:
cd ~/Desktop
rm -rf linkedin-post-agent
- On Windows, run:
cd ~\Desktop
rmdir /s /q linkedin-post-agent
- Verify the DynamoDB table is gone by running:
aws dynamodb list-tables
You should no longer see linkedin-posts in the output.
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-grade LangGraph agent that fetches real news, deduplicates against your history, generates a LinkedIn post in your voice, and delivers it straight to your inbox.
You've learned how to:
- Built a multi-node state graph using LangGraph with sequential node execution, passing structured state between fetch_news, filter_duplicates, generate_post, and notify nodes.
- Implemented DynamoDB-based deduplication so the agent never repeats a topic, and grounded LLM output with professional context files (cv.md and humanizer.md) for authentic, domain-specific writing.
- Delivered generated posts to your inbox via AWS SNS email notifications, using NVIDIA NIM as an OpenAI-compatible LLM endpoint for content generation.
- Secret Mission: Added a relevance scoring node that ranks fetched articles by alignment with your CV before selecting the best one for post generation.
Ready to quiz yourself?