Build a Local LinkedIn Drafting Agent

Build a local AI agent that drafts and revises LinkedIn posts with approval.

Introduction

30 Second Summary

An organization post carries a public voice from the moment someone reads it. Turning a few notes into something accurate often takes several rounds of careful editing.

In this project, you will build a local LinkedIn drafting agent with Python through Ollama's local HTTP API. You will guide each revision before saving the post you approve.

What You'll Build

Your organization brief becomes a LinkedIn draft that changes with each round of feedback until the approved version lands in a local text file.

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

  • A grounded first draft that starts with a selected topic based only on the organization facts you provide.
  • A visible revision loop where each written request produces an updated post on your screen.
  • A human approval checkpoint that writes only your accepted post to approved_post.txt.
  • Secret Mission: Save a complete JSON revision history of every draft plus its feedback or approval decision in draft_history.json.

Are there any prerequisites?

You need a Mac running macOS Sonoma 14 or newer with Python 3, Terminal, and Visual Studio Code.

Keep about 2.5 GB free for the Qwen3 4B model plus extra space for Ollama.

Before We Start

Before any model writes on your organization’s behalf, take a moment to define the communication goal. Your one-sentence goal should name the organization, the intended audience, and why the LinkedIn post should exist.

Set Up the Local Model API

Your drafting program needs a model endpoint before it can generate any text. Right now, Python has nowhere local to send a request.

Ollama runs the Qwen3 4B model on your Mac. Visual Studio Code keeps the project files beside the integrated Terminal.

In this step, get ready to:
  • Run Ollama v0.40.14 on a compatible Mac.
  • Create the local-linkedin-agent workspace in Visual Studio Code.
  • Verify that a downloaded Qwen3 model answers through the local HTTP API.
Confirm compatibility and install Ollama

Local inference runs model calculations on your Mac. Your organization brief stays away from a cloud API.

  • Press Cmd+Space to open Spotlight.
  • Type Terminal.
  • Press Return to open Terminal.
  • Check your installed macOS version by running this command:
sw_vers -productVersion

What does this command show?

The command prints the macOS product version installed on your Mac. The first number is its major version.

You should see 14 or a higher first number. This meets Ollama's requirement for macOS Sonoma 14 or newer.

  • Continue if the first number is 14 or higher.
  • Follow Apple's Software Update guide if the first number is lower than 14.
  • Return to this version check after the macOS update finishes.

Ollama v0.40.14 provides the local server that the drafting script needs. First, check whether its command is already available.

  • Check the installed Ollama version by running this command:
ollama --version

What does this command check?

The command asks the Ollama CLI to report its installed version. The result determines which setup path you need.

✔️ I see v0.40.14

Your installed version matches v0.40.14. Start its local server by opening the Ollama app.

  • Press Cmd+Space to open Spotlight.
  • Type Ollama.
  • Press Return to open Ollama.

ⓧ I see an older version

The installer may take a few minutes. Your Mac may request administrator approval for the local installation.

  • Install the current Ollama release by running these commands:
curl -fsSL https://ollama.com/install.sh | sh
ollama --version

What do these commands do?

  • The first command downloads the official installer before running it through the shell.
  • The second command reports the version that is now installed.

The second command should report Ollama v0.40.14.

  • Press Cmd+Space to open Spotlight.
  • Type Ollama.
  • Press Return to start the local server.

ⓧ Command not found

The installation may take a few minutes. Your Mac may request administrator approval before it adds Ollama.

  • Install Ollama before checking its version by running these commands:
curl -fsSL https://ollama.com/install.sh | sh
ollama --version

What do these commands do?

  • The first command downloads the official installer before running it through the shell.
  • The second command confirms that the Ollama CLI is available.

The second command should report Ollama v0.40.14.

  • Press Cmd+Space to open Spotlight.
  • Type Ollama.
  • Press Return to start the local server.

Ollama still unavailable?

Close Terminal after the installer finishes. Open a fresh Terminal window so it can find the new command.

If Ollama does not appear in Spotlight, help me check why Ollama is unavailable on my Mac.

Create the project workspace

A Visual Studio Code workspace keeps every project file inside one named folder. Its integrated Terminal starts from that same folder.

  • Press Cmd+Space to open Spotlight.
  • Type Visual Studio Code.
  • Press Return to open Visual Studio Code.
  • Click File in the menu bar.
  • Select Open Folder....
  • Select Desktop in the folder dialog.
  • Click New Folder.
  • Enter local-linkedin-agent as the folder name.

Why open a folder as a workspace?

The opened folder becomes the workspace boundary. The integrated Terminal starts from that folder by default.

  • Press Return to create the folder.
  • Select the local-linkedin-agent folder.
  • Click Open.
  • Select Yes, I trust the authors if the Workspace Trust dialog appears.
  • Click View in the menu bar.
  • Select Terminal.

You should see local-linkedin-agent in the Explorer sidebar. The integrated Terminal should show the same folder in its prompt.

Prepare the local model

The primary qwen3:4b model uses about 2.5 GB of storage. Ollama downloads that model before its local API can generate a response.

This download can take several minutes on the first run. A moving progress display means Ollama is still working.

  • Keep your Mac connected to power during the model download.
  • Download the primary model from the integrated Terminal by running:
ollama pull qwen3:4b

What does this command do?

Ollama downloads the qwen3:4b model files to your Mac. Later requests can use those local files without an API key.

The terminal prompt returns when the model download completes. Your Mac now has the primary model available locally.

Model download interrupted?

Check that your internet connection is active. Run the same pull command again so Ollama can retry the download.

For model download help, help me troubleshoot an interrupted Ollama model pull.

Before you send the verification request, do you expect the model's reply to appear as plain text or inside a JSON response?

  • Send a non-streaming chat request to the local API by running:
curl http://localhost:11434/api/chat -d '{"model":"qwen3:4b","messages":[{"role":"user","content":"Reply with exactly: API ready"}],"stream":false,"think":false}'

What does this request prove?

  • The model field selects the downloaded Qwen3 model.
  • The messages field supplies one user prompt.
  • "stream":false requests one complete JSON response.
  • "think":false requests the final answer without thinking output.

You should see one JSON object. Its message.content value contains API ready.

✔️ JSON contains message.content

That is the key connection working. Your local model has accepted a chat request through Ollama's API.

ⓧ The 4B model cannot load

The smaller qwen3:0.6b fallback uses about 523 MB of storage. It keeps the same local API workflow with a lighter model.

  • Download the fallback model by running:
ollama pull qwen3:0.6b

What does this command do?

Ollama downloads the smaller qwen3:0.6b model. The local chat endpoint stays the same.

Before you retry, do you expect changing only the model identifier to preserve the request format?

  • Press the Up Arrow key to recall the verification request.
  • Replace qwen3:4b with qwen3:0.6b.
  • Press Return to send the updated request.

You should now see a JSON response whose message.content value contains API ready.

ⓧ I cannot reach Ollama

The local API responds only while the Ollama app is running. Start the app before retrying the same request.

  • Press Cmd+Space to open Spotlight.
  • Type Ollama.
  • Press Return to start Ollama.

Before you retry, do you expect the running local server to answer the same request?

  • Switch back to the integrated Terminal.
  • Press the Up Arrow key to recall the request.
  • Press Return to send it again.

You should see a JSON object whose message.content value contains API ready.

  • Record the model identifier that your future MODEL constant should use: your working model identifier.

Still missing a local response?

Confirm that Ollama remains open. Check that the model identifier in the request matches the model you downloaded.

If the API still does not answer, help me diagnose my local Ollama chat request.

Your local model endpoint is ready. Next, you will call it from Python to generate the first visible LinkedIn draft.

Generate the First Visible Draft

Your local Ollama API responded in the previous step. The project still needs a Python program that can use it.

In this step, you'll create one direct HTTP request with Python. The returned JSON becomes your first visible LinkedIn draft.

In this step, get ready to:
  • Create the Python entry point with standard-library HTTP tools.
  • Send a JSON POST request to the local model.
  • Display a generated LinkedIn draft in the terminal.
Create the Python entry point

The script starts with standard-library imports plus two constants. These values identify the local endpoint and the model that handles each request.

  • In the VS Code file sidebar from earlier, click the new-file icon.
  • Type linkedin_agent.py.
  • Press Enter.
  • Add the script foundation by pasting this code:
import json
from urllib.request import Request, urlopen

API_URL = "http://localhost:11434/api/chat"
MODEL = "qwen3:4b"

What does this foundation do?

  • The json module converts Python data into JSON text.
  • The Request class describes the outgoing HTTP request.
  • The urlopen function sends the request to the endpoint.
  • The API_URL constant stores the local chat endpoint.
  • The MODEL constant selects the downloaded model.
  • Save linkedin_agent.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm the VS Code file sidebar lists linkedin_agent.py inside local-linkedin-agent.

Using the fallback model?

  • Change the MODEL value to qwen3:0.6b if that is the model you verified in Step 1.

Can't create the file?

  • Confirm the file sidebar shows the local-linkedin-agent folder from the previous step.
  • Check that the filename ends with .py.

Help me create the Python file in the correct VS Code folder.

Send the JSON request

A POST request carries the prompt to the local chat endpoint. Its payload controls the model response through explicit request fields.

  • In linkedin_agent.py, place your cursor on the blank line below the MODEL constant.
  • Add the request by pasting this code:
payload = {
    "model": MODEL,
    "messages": [
        {"role": "user", "content": "Write a short LinkedIn post about professional learning."}
    ],
    "stream": False,
    "think": False,
}
request = Request(
    API_URL,
    data=json.dumps(payload).encode("utf-8"),
    headers={"Content-Type": "application/json"},
    method="POST",
)
with urlopen(request) as response:
    body = response.read().decode("utf-8")

What does this request do?

  • The payload dictionary holds the request body.
  • The messages list gives the model one user prompt.
  • The stream value requests one complete response.
  • The think value requests only the final answer.
  • The json.dumps() call converts the payload into JSON text.
  • The encode() call converts that text into UTF-8 bytes.
  • The Content-Type header identifies the body as JSON.
  • The urlopen() call sends the request.
  • The with block reads the response into body.
  • Save linkedin_agent.py.
  • Return to the integrated Terminal from earlier.
  • Check that the request completes by running this command:
python3 linkedin_agent.py

What does this run prove?

The command executes the saved Python file. When the terminal returns to its prompt without a Python traceback, the request has completed against the local endpoint.

Request not completing?

  • Confirm Ollama is still open on your Mac.
  • Check that MODEL matches the model you verified in Step 1.
  • Compare API_URL with the local endpoint from the previous step.

Help me diagnose why my Python request cannot reach my local Ollama endpoint.

Print the returned draft

The response body still contains JSON text. The script needs to decode that text before it can display the assistant's message.

  • Switch back to linkedin_agent.py in VS Code.
  • Place your cursor below body = response.read().decode("utf-8").
  • Add the response handling by pasting this code:
data = json.loads(body)
draft = data["message"]["content"]
print(draft)

How does the draft reach the terminal?

  • The json.loads() call converts the response text into Python data.
  • The message.content path contains the assistant's final text.
  • The draft variable stores that text.
  • The print() call displays the draft in the terminal.
  • Save linkedin_agent.py.

Before you run the completed script, do you think it can keep listening after the first draft appears? Make your prediction before continuing.

  • Return to the integrated Terminal from earlier.
  • Generate your first visible draft by running this command:
python3 linkedin_agent.py

What should you see?

You'll see a model-generated professional-learning topic developed into a short LinkedIn post. The exact wording varies with each generated response.

That first draft is a real milestone. Your Python code is now generating text through a local model API.

After the draft prints, the terminal returns to its prompt.

The program cannot collect organization facts. It cannot accept feedback or approval.

That one-shot limit is the intended result for this first version.

Draft missing?

  • Confirm Ollama is still open on your Mac.
  • Check that MODEL matches the model downloaded in Step 1.
  • Compare the three response lines with the code above.

Help me find why my local LinkedIn draft is missing from the terminal.

✔️ Awesome, I've got everything!

Your complete script is saved. A fallback setup differs only in the MODEL value.

ⓧ I'd like to double check the full code

import json
from urllib.request import Request, urlopen

API_URL = "http://localhost:11434/api/chat"
MODEL = "qwen3:4b"

payload = {
    "model": MODEL,
    "messages": [
        {"role": "user", "content": "Write a short LinkedIn post about professional learning."}
    ],
    "stream": False,
    "think": False,
}
request = Request(
    API_URL,
    data=json.dumps(payload).encode("utf-8"),
    headers={"Content-Type": "application/json"},
    method="POST",
)
with urlopen(request) as response:
    body = response.read().decode("utf-8")

data = json.loads(body)
draft = data["message"]["content"]
print(draft)

The local request now works from end to end. Next, you'll ground topic selection in an organization brief so the draft has real context.

Ground the Topic in an Organization Brief

Your one-shot script already proves that Ollama can return a LinkedIn draft. Its draft is generic because the model receives no organization facts.

This step grounds topic selection in a brief that you supply. You will also add readable error handling around the local HTTP request.

In this step, get ready to:
  • Create a reusable local API function with readable failure messages.
  • Collect an organization brief through terminal prompts.
  • Generate a topic and post using only the supplied facts.
Refactor the local API call

A reusable function keeps JSON serialization and response decoding in one place. Exception handling converts connection failures or unexpected responses into messages you can act on.

  • In linkedin_agent.py, replace the two current import lines with this import group:
import json
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

What do these imports add?

  • The json module continues to encode request data and decode response data.
  • HTTPError provides the status code and response details when the local API rejects a request.
  • URLError handles failures to connect to the local Ollama server.
  • Request and urlopen continue to create and send the request.
  • Save linkedin_agent.py.
  • Confirm the existing draft still runs by running this command:
python3 linkedin_agent.py

What should you see?

You should see one generic LinkedIn draft as before. This confirms the expanded import group did not break the existing request.

Seeing an import failure?

  • Compare the three import lines with the code above.
  • Check that each import starts at the far left of linkedin_agent.py.

Help me fix the import failure in my Python script.

  • In linkedin_agent.py, replace everything below the MODEL constant with this function:
def call_ollama(messages):
    payload = {
        "model": MODEL,
        "messages": messages,
        "stream": False,
        "think": False,
    }
    request = Request(
        API_URL,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urlopen(request, timeout=120) as response:
            body = response.read().decode("utf-8")
    except HTTPError as error:
        raise RuntimeError(f"Ollama returned HTTP {error.code}: {error.read().decode('utf-8')}") from error
    except URLError as error:
        raise RuntimeError("Could not reach Ollama at http://localhost:11434. Open Ollama and confirm the model is installed.") from error
    try:
        data = json.loads(body)
        return data["message"]["content"].strip()
    except (json.JSONDecodeError, KeyError) as error:
        raise RuntimeError("Ollama returned an unexpected response shape.") from error

What does this function do?

  • call_ollama(messages) accepts the conversation that the model should follow.
  • payload combines that conversation with the selected model and non-streaming settings.
  • Request creates the POST request with a JSON content header.
  • HTTPError and URLError turn request failures into readable runtime messages.
  • json.loads(body) decodes the response before the function returns the trimmed assistant content.
  • Save linkedin_agent.py.
  • Check that Python can load the refactored function by running:
python3 linkedin_agent.py

What should you see?

The terminal should return to a new prompt without a traceback. That clean return confirms Python loaded the imports and function definition.

Seeing a traceback?

  • Check that every line inside call_ollama(messages) uses four spaces of indentation.
  • Check that each except line aligns with its matching try line.

Help me diagnose the traceback in call_ollama().

Build the grounded messages

The model needs clear boundaries around the facts it can use. A system message sets those boundaries before a user message supplies the organization brief.

  • Add this function below call_ollama(messages) in linkedin_agent.py.
def build_initial_messages(organization, description, audience, source_note):
    verified_context = source_note or "No verified current update was supplied."
    system_message = """/no_think
You are a careful LinkedIn editor drafting for an organization page.
Use only facts supplied by the user.
Never invent metrics, dates, customers, awards, partnerships, quotes, or events.
If no verified update is supplied, choose an evergreen topic that needs no unsupported factual claim.
Return only the draft. Start with 'Topic:' on one line, then a blank line, then the post.
"""
    user_message = f"""Organization: {organization}
Description: {description}
Audience: {audience}
Verified source note: {verified_context}

Choose one useful topic and draft the post now.
"""
    return [
        {"role": "system", "content": system_message},
        {"role": "user", "content": user_message},
    ]

How does grounding work?

  • verified_context uses the supplied source note when one exists.
  • The fallback sentence tells the model that no verified current update is available.
  • system_message prohibits unsupported metrics, dates, customers, awards, partnerships, quotes, and events.
  • user_message labels each fact so the model can separate the organization from its audience and source note.
  • The returned message list gives the model its rules before it receives the brief.
  • Save linkedin_agent.py.
  • Check that Python can load the message builder by running:
python3 linkedin_agent.py

What should you see?

The terminal should return to a new prompt without a traceback. This confirms the multiline messages and returned list have valid Python syntax.

Seeing a string or syntax problem?

  • Check that both multiline messages begin with three double quotes.
  • Check that both multiline messages end with three double quotes.
  • Check that the final returned list has matching square brackets.

Help me fix the multiline message syntax.

Collect the brief and generate a tailored draft

The program now needs an entry point that collects four facts from you. Those values become the grounded messages sent to the local model.

  • Add this code below build_initial_messages() in linkedin_agent.py.
def main():
    organization = input("Organization name: ").strip()
    description = input("What does the organization do? ").strip()
    audience = input("Who should this post reach? ").strip()
    source_note = input("Verified update or source note, or press Enter for evergreen: ").strip()
    messages = build_initial_messages(organization, description, audience, source_note)
    print(call_ollama(messages))


if __name__ == "__main__":
    try:
        main()
    except RuntimeError as error:
        print(f"\nError: {error}")

How does the entry point work?

  • main() collects the organization name, description, audience, and optional source note.
  • strip() removes unwanted spaces from each answer.
  • build_initial_messages() turns those answers into the grounded conversation.
  • call_ollama() sends the conversation to the local model.
  • The final try block prints readable runtime failures instead of an unexplained traceback.
  • Save linkedin_agent.py.

Before you run the script, do you think the selected topic will stay within the organization brief when no verified update is supplied?

  • Start the organization prompts by running this command:
python3 linkedin_agent.py

What does this command do?

This command starts linkedin_agent.py from the integrated Terminal. The script pauses for four answers before it sends the grounded request to Ollama.

  • Enter Northstar Learning at the Organization name prompt.
  • Enter We help early-career professionals build practical technology skills. at the What does the organization do? prompt.
  • Enter People starting careers in technology at the Who should this post reach? prompt.
  • Press Enter at the Verified update or source note prompt.

You should see output beginning with Topic: followed by a blank line and a LinkedIn post. The topic should suit the organization description without claiming an unsupported current event.

  • Compare the selected topic with the organization description.
  • Confirm that the post targets people starting careers in technology.
  • Confirm that the draft contains no unsupported metrics, dates, customers, awards, partnerships, quotes, or events.

That is the grounding layer working: your local model now drafts from the facts you supplied.

Draft not appearing?

  • Confirm that Ollama remains open on your Mac.
  • Confirm that MODEL still names the model you installed earlier.
  • Compare the indentation in main() with the completed file below.

Help me diagnose why my grounded draft is not appearing.

Use these tabs to compare your finished one-draft script with the target version.

✔️ Awesome, I've got everything!

Your saved script now matches the grounded one-draft version.

ⓧ I'd like to double check the full code

import json
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

API_URL = "http://localhost:11434/api/chat"
MODEL = "qwen3:4b"


def call_ollama(messages):
    payload = {
        "model": MODEL,
        "messages": messages,
        "stream": False,
        "think": False,
    }
    request = Request(
        API_URL,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urlopen(request, timeout=120) as response:
            body = response.read().decode("utf-8")
    except HTTPError as error:
        raise RuntimeError(f"Ollama returned HTTP {error.code}: {error.read().decode('utf-8')}") from error
    except URLError as error:
        raise RuntimeError("Could not reach Ollama at http://localhost:11434. Open Ollama and confirm the model is installed.") from error
    try:
        data = json.loads(body)
        return data["message"]["content"].strip()
    except (json.JSONDecodeError, KeyError) as error:
        raise RuntimeError("Ollama returned an unexpected response shape.") from error


def build_initial_messages(organization, description, audience, source_note):
    verified_context = source_note or "No verified current update was supplied."
    system_message = """/no_think
You are a careful LinkedIn editor drafting for an organization page.
Use only facts supplied by the user.
Never invent metrics, dates, customers, awards, partnerships, quotes, or events.
If no verified update is supplied, choose an evergreen topic that needs no unsupported factual claim.
Return only the draft. Start with 'Topic:' on one line, then a blank line, then the post.
"""
    user_message = f"""Organization: {organization}
Description: {description}
Audience: {audience}
Verified source note: {verified_context}

Choose one useful topic and draft the post now.
"""
    return [
        {"role": "system", "content": system_message},
        {"role": "user", "content": user_message},
    ]


def main():
    organization = input("Organization name: ").strip()
    description = input("What does the organization do? ").strip()
    audience = input("Who should this post reach? ").strip()
    source_note = input("Verified update or source note, or press Enter for evergreen: ").strip()
    messages = build_initial_messages(organization, description, audience, source_note)
    print(call_ollama(messages))


if __name__ == "__main__":
    try:
        main()
    except RuntimeError as error:
        print(f"\nError: {error}")

Your script now selects a grounded topic and produces one tailored post. Next, you will keep the agent running so written feedback can shape each revision before approval.

Add the Approval and Revision Loop

Your grounded Python script can produce a tailored LinkedIn post. However, the script exits before you can accept the draft or request a change.

This step adds a human-in-the-loop approval process. Each attempt stays under your control until you approve the final wording.

In this step, get ready to:
  • Label each generated draft with an attempt number.
  • Retain written feedback in the message history.
  • Save the approved draft to approved_post.txt.
Expose the missing approval checkpoint

The current script demonstrates the limitation that this step solves. It generates one draft without offering a decision point.

Before you run the current version, do you expect the script to wait for feedback after displaying its draft?

  • Run the current one-shot script from the integrated Terminal by using this command:
python3 linkedin_agent.py

What does this command prove?

This runs the grounded request flow you built earlier. The model receives the organization brief through the local Ollama API.

  • Answer the four organization prompts with the brief you used earlier.

You will see a selected topic followed by a post. The process then ends without asking whether the draft is acceptable.

Did the draft fail to appear?

Return to the Ollama application from earlier if the script reports that it cannot reach the local server. Confirm that your selected Qwen3 model is still installed.

Check the error printed by linkedin_agent.py before changing any code. Help me diagnose why my local drafting script did not produce a draft.

Prepare controlled draft attempts

A revision loop can accumulate a longer conversation. Generation limits keep each attempt focused while preserving enough context for the organization brief.

  • In linkedin_agent.py, locate the "think": False, line inside payload.
  • Replace that line with this request configuration:
        "think": False,
        "options": {
            "num_ctx": 4096,
            "num_predict": 500,
        },

What does this configuration do?

  • The num_ctx value sets the context capacity used for the organization brief plus revision history.
  • The num_predict value limits how many tokens the model generates for one attempt.

Readable API failures become more important once the script can make several requests. A failed revision should still explain whether the local service rejected the request or returned an unexpected response.

  • In call_ollama(messages), locate the exception handlers beneath the urlopen request.
  • Replace the handlers through the response-parsing block with this version:
    except HTTPError as error:
        details = error.read().decode("utf-8")
        raise RuntimeError(
            f"Ollama returned HTTP {error.code}: {details}"
        ) from error
    except URLError as error:
        raise RuntimeError(
            "Could not reach Ollama at http://localhost:11434. "
            "Open Ollama and confirm the model is installed."
        ) from error

    try:
        data = json.loads(body)
        return data["message"]["content"].strip()
    except (json.JSONDecodeError, KeyError) as error:
        raise RuntimeError(
            "Ollama returned an unexpected response shape."
        ) from error

How does error handling help?

  • The details variable preserves the response body when the local API returns an HTTP failure.
  • The final handler converts malformed JSON or a missing response field into a readable runtime message.
  • Save linkedin_agent.py.

Local generation can pause while the model works. A quiet terminal during generation can be normal.

  • Confirm the request still produces a grounded draft by running this command:
python3 linkedin_agent.py

What does this check confirm?

This check confirms that the new request options still produce a complete local response. It also confirms that the revised exception structure did not break the successful path.

  • Answer the four organization prompts with your brief.

You should still see a topic followed by a grounded post. The script should return to the Terminal prompt after the draft.

Revision works best when every attempt follows the same shape. The system message will now set a clear length range plus a hashtag requirement.

  • In build_initial_messages(), replace the existing system_message assignment with this version:
    system_message = """/no_think
You are a careful LinkedIn editor drafting for an organization page.
Use only facts supplied by the user.
Never invent metrics, dates, customers, awards, partnerships, quotes, or events.
If no verified update is supplied, choose an evergreen topic about the organization's mission, process, learning, or general field that needs no unsupported factual claim.
Return only the draft. Start with 'Topic:' on one line, then a blank line, then the post.
Keep the post between 90 and 140 words and finish with one to three relevant hashtags.
"""

Why tighten the draft instructions?

The expanded evergreen rule gives the model safe topic categories when no current update is supplied. The length requirement keeps each revision suitable for quick human review.

The loop also needs reusable state before it starts. The opening of main() will hold the initial messages plus the current attempt number.

  • In main(), replace the block from def main(): through the current messages assignment with this setup:
  • Leave the existing one-shot print(call_ollama(messages)) line directly beneath the new setup for this check.
def main():
    print("Local LinkedIn Drafting Agent")
    organization = input("Organization name: ").strip()
    description = input("What does the organization do? ").strip()
    audience = input("Who should this post reach? ").strip()
    source_note = input(
        "Verified update or source note, or press Enter for evergreen: "
    ).strip()

    messages = build_initial_messages(
        organization,
        description,
        audience,
        source_note,
    )
    attempt = 1

What state does the loop need?

  • The messages list begins with the grounded system message plus the organization brief.
  • The attempt variable starts at 1 so every displayed draft can be identified.
  • Save linkedin_agent.py.
  • Check the updated setup by running this command:
python3 linkedin_agent.py

What should this run establish?

The script now prints its title before collecting the brief. It still generates one draft because the existing one-shot call remains in place.

  • Answer each organization prompt with your brief.

You should see Local LinkedIn Drafting Agent above the prompts. The generated post should contain between 90 and 140 words followed by relevant hashtags.

Add approval and feedback decisions

The approval loop turns each generated draft into a decision. Approval saves the current result while empty input keeps the same attempt active.

  • In main(), locate the remaining print(call_ollama(messages)) line.
  • Replace that line with this initial approval loop:
    while True:
        print(f"\nGenerating draft {attempt}...")
        draft = call_ollama(messages)
        print(f"\nDraft {attempt}\n{'=' * 40}\n{draft}\n{'=' * 40}")

        decision = input(
            "Type 'approve' or enter feedback for the next revision: "
        ).strip()

        if decision.lower() == "approve":
            with open("approved_post.txt", "w", encoding="utf-8") as file:
                file.write(draft + "\n")
            print("Approved post saved to approved_post.txt")
            break

        if not decision:
            print("Enter feedback or type 'approve'.")
            continue

How does approval work?

  • The while True loop keeps the decision process active until an approved draft is saved.
  • The decision.lower() check accepts capitalization variations of approve.
  • The file block writes the accepted draft with UTF-8 encoding plus a final newline.
  • The empty-input branch displays guidance without advancing the attempt.

Before you test this branch, what do you expect the script to create after you enter approve?

  • Save linkedin_agent.py.
  • Test the approval path by running this command:
python3 linkedin_agent.py

What does this test cover?

This run exercises draft numbering plus the approval branch. Entering approve should end the loop only after the displayed draft has been written locally.

  • Answer the organization prompts with your brief.
  • Enter approve at the decision prompt.

You should see Draft 1 followed by Approved post saved to approved_post.txt.

Free-form feedback needs one more branch. The displayed draft becomes an assistant message while your requested change becomes the next user message.

  • Inside the while True loop, locate the empty-decision branch that ends with continue.
  • Add this feedback-history block directly beneath that branch:
        messages.append({"role": "assistant", "content": draft})
        messages.append(
            {
                "role": "user",
                "content": (
                    "Revise the draft using this feedback: "
                    f"{decision}\nReturn only the complete revised draft."
                ),
            }
        )
        attempt += 1

How does feedback change the next attempt?

  • The assistant message records the exact draft that the learner reviewed.
  • The next user message attaches the requested change to that draft.
  • The incremented attempt value gives the next result a new label.

Use the complete reference to catch indentation or formatting differences before the final run.

✔️ Awesome, I've got everything!

Save linkedin_agent.py before running the final approval test.

ⓧ I'd like to double check the full code

import json
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

API_URL = "http://localhost:11434/api/chat"
MODEL = "qwen3:4b"


def call_ollama(messages):
    payload = {
        "model": MODEL,
        "messages": messages,
        "stream": False,
        "think": False,
        "options": {
            "num_ctx": 4096,
            "num_predict": 500,
        },
    }
    request = Request(
        API_URL,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )

    try:
        with urlopen(request, timeout=120) as response:
            body = response.read().decode("utf-8")
    except HTTPError as error:
        details = error.read().decode("utf-8")
        raise RuntimeError(
            f"Ollama returned HTTP {error.code}: {details}"
        ) from error
    except URLError as error:
        raise RuntimeError(
            "Could not reach Ollama at http://localhost:11434. "
            "Open Ollama and confirm the model is installed."
        ) from error

    try:
        data = json.loads(body)
        return data["message"]["content"].strip()
    except (json.JSONDecodeError, KeyError) as error:
        raise RuntimeError(
            "Ollama returned an unexpected response shape."
        ) from error


def build_initial_messages(organization, description, audience, source_note):
    verified_context = source_note or "No verified current update was supplied."

    system_message = """/no_think
You are a careful LinkedIn editor drafting for an organization page.
Use only facts supplied by the user.
Never invent metrics, dates, customers, awards, partnerships, quotes, or events.
If no verified update is supplied, choose an evergreen topic about the organization's mission, process, learning, or general field that needs no unsupported factual claim.
Return only the draft. Start with 'Topic:' on one line, then a blank line, then the post.
Keep the post between 90 and 140 words and finish with one to three relevant hashtags.
"""

    user_message = f"""Organization: {organization}
Description: {description}
Audience: {audience}
Verified source note: {verified_context}

Choose one useful topic and draft the post now.
"""

    return [
        {"role": "system", "content": system_message},
        {"role": "user", "content": user_message},
    ]


def main():
    print("Local LinkedIn Drafting Agent")
    organization = input("Organization name: ").strip()
    description = input("What does the organization do? ").strip()
    audience = input("Who should this post reach? ").strip()
    source_note = input(
        "Verified update or source note, or press Enter for evergreen: "
    ).strip()

    messages = build_initial_messages(
        organization,
        description,
        audience,
        source_note,
    )
    attempt = 1

    while True:
        print(f"\nGenerating draft {attempt}...")
        draft = call_ollama(messages)
        print(f"\nDraft {attempt}\n{'=' * 40}\n{draft}\n{'=' * 40}")

        decision = input(
            "Type 'approve' or enter feedback for the next revision: "
        ).strip()

        if decision.lower() == "approve":
            with open("approved_post.txt", "w", encoding="utf-8") as file:
                file.write(draft + "\n")
            print("Approved post saved to approved_post.txt")
            break

        if not decision:
            print("Enter feedback or type 'approve'.")
            continue

        messages.append({"role": "assistant", "content": draft})
        messages.append(
            {
                "role": "user",
                "content": (
                    "Revise the draft using this feedback: "
                    f"{decision}\nReturn only the complete revised draft."
                ),
            }
        )
        attempt += 1


if __name__ == "__main__":
    try:
        main()
    except RuntimeError as error:
        print(f"\nError: {error}")

What should you compare?

This reference shows the cumulative script for the completed project. Check the indentation of both message-history additions because they must remain inside the approval loop.

Before the final run, do you expect the second draft to follow your feedback while retaining the original organization facts?

  • Run the completed drafting agent by using this command:
python3 linkedin_agent.py

What does the final run verify?

This run exercises the complete feedback cycle. The second request includes the initial brief plus the first draft plus your revision instruction.

  • Answer the organization prompts with your brief.
  • Enter make the opening more direct at the first decision prompt.
  • Compare Draft 2 with Draft 1.
  • Enter approve at the second decision prompt.

You should see two numbered attempts. The second draft should use a more direct opening while retaining the supplied facts.

The terminal should finish with Approved post saved to approved_post.txt.

  • In the VS Code file sidebar, select approved_post.txt.
  • Confirm that the file contains the accepted second draft.

Does the second draft ignore your feedback?

Check that both messages.append() calls are inside the loop. Confirm that attempt += 1 has the same indentation.

Compare the role values with the complete reference. Help me debug why Draft 2 does not reflect my feedback.

That is the control layer working. Your local drafting agent now revises from written feedback until you approve a result.

Secret mission

Preserve the Revision History

Your agent currently saves the approved post while earlier drafts disappear. Extend it with a structured revision history that records every draft, feedback decision, and approval in a local JSON file.

Clean Up Your Resources

Clean Up Your Resources

Your agent runs entirely on your Mac with no cloud resources or per-token charges. Decide whether to keep the local files, pause the selected model, or delete its files to recover storage.

Resources you used:

  • The selected Qwen3 model is stored through Ollama as qwen3:4b at about 2.5 GB or qwen3:0.6b at about 523 MB.
  • The local-linkedin-agent folder contains linkedin_agent.py, approved_post.txt, and draft_history.json.

Keep everything running

No action needed. Choose this if you want the drafting agent ready for more posts or future experiments.

  • Keep Ollama installed for future local model projects.
  • Keep the selected Qwen3 model downloaded so the agent can generate drafts without another download.
  • Keep the local-linkedin-agent folder so your script, approved post, and revision history remain available.

Pause - I'll come back to this later

Pausing frees the memory used by the running model while preserving its downloaded files. Your script and saved drafts remain unchanged.

  • Stop the primary model by running this command if you used qwen3:4b:
ollama stop qwen3:4b

What does this command do?

This stops the running 4B model process. The downloaded model stays on your Mac for your next session.

  • Stop the fallback model by running this command if you used qwen3:0.6b:
ollama stop qwen3:0.6b

What does this command do?

This stops the running fallback model process. Its downloaded files remain ready for another drafting session.

Your selected model is now paused. The agent files remain ready for your return.

Delete - I don't want to use this again

Deletion permanently removes the downloaded model plus every script and saved draft in the project folder. Your Ollama application remains available for future local projects.

  • Copy any drafts you want to keep outside the local-linkedin-agent folder.
  • Remove the primary model by running this command if you downloaded qwen3:4b:
ollama rm qwen3:4b

What does this command do?

This removes the 4B model from Ollama's local storage. Your project folder remains untouched.

  • Remove the fallback model by running this command if you downloaded qwen3:0.6b:
ollama rm qwen3:0.6b

What does this command do?

This removes the fallback model from Ollama's local storage. Your project folder remains untouched.

  • Delete the project folder from the integrated Terminal by running these commands:
cd ..
rm -rf local-linkedin-agent

What do these commands do?

The first line moves the terminal to the folder above your project. The second line permanently removes the project folder with its script and saved drafts.

Your model download and project files are now removed. Ollama remains installed on your Mac.

Nice Work!

Nice Work!

You did it! You've built a local LinkedIn drafting agent that turns an organization brief into a grounded draft through Ollama. Qwen3 revises each draft from your feedback until you approve a post for local saving.

You've learned how to:

  • Send direct HTTP POST requests from Python to a local model API. You encoded request bodies as JSON. You decoded model responses with Python's standard library.
  • Apply prompt grounding through an organization brief. You constrained topic selection to verified updates or evergreen ideas.
  • Preserve message history so written feedback changes the next draft. You added a human approval loop before saving the accepted post to approved_post.txt. You also handled connection failures and unexpected responses with readable messages.
  • Secret Mission: Preserved every attempt and decision in draft_history.json. This structured audit trail records revision feedback plus the final approval.

Ready to quiz yourself?