Course ES

Chapter 3 of 21 · basic

Prompting fundamentals

What this session covers

Week one got an answer back from a model. It also let the model decide everything about that answer — how long, what tone, what shape. This hour is about taking that control back. The prompt is the one lever you have on a system you can't otherwise reach into, and most of the difference between a toy and a tool is how you pull it.

The whole lesson fits in one move: split what you say to the model into a system instruction and a user message. The system prompt sets the role and the rules. The user message carries the actual input. Same model, same question, but the answer changes completely depending on what you put in that system slot. I wrote the basic example to ask one question twice — once bare, once with a system instruction — so you can read the two answers and see the lever move.

OpenAI

The Responses API splits the two cleanly. The system prompt is the instructions argument; the user's text stays in input. Send the question with no instructions and you get whatever the model feels like — usually a hedged, padded paragraph or two. Add an instruction that pins the role and caps the length, and the same question comes back tight and direct.

"""Week 2 - Prompting fundamentals (OpenAI).

One question, asked twice. First with nothing but the question. Then
with a system instruction that pins the role and the output shape. The
model is identical; only the prompt moves. Read the two answers and the
whole lesson is right there.
"""
import os
import sys

from dotenv import find_dotenv, load_dotenv
from openai import OpenAI

load_dotenv(find_dotenv())

if not os.environ.get("OPENAI_API_KEY"):
    sys.exit("OPENAI_API_KEY is not set. Put it in the course-root .env file.")

client = OpenAI()

QUESTION = "Should I use a vector database for my app?"

# region: bare-call
# No system prompt. The model picks its own role, length, and format.
bare = client.responses.create(model="gpt-5.4-nano", input=QUESTION)
# endregion

# region: system-call
# The system instruction sets the role AND pins the output shape. In the
# Responses API the system prompt is the `instructions` argument; the
# user's text stays in `input`.
SYSTEM = (
    "You are a senior backend engineer. Answer in at most three sentences. "
    "Lead with a direct yes/no/it-depends, then the single deciding factor. "
    "No preamble, no bullet points."
)
guided = client.responses.create(
    model="gpt-5.4-nano", instructions=SYSTEM, input=QUESTION,
)
# endregion

print("=== bare prompt ===\n")
print(bare.output_text)
print("\n=== with a system instruction ===\n")
print(guided.output_text)

Watch what the system prompt is actually doing. It's not just "be a backend engineer" — the role is the cheap part. The work is in the constraints: at most three sentences, lead with the verdict, no bullet points. Vague roles drift. Specific rules hold. That's the habit worth building now: write the system prompt as a short list of rules the answer has to satisfy, not as a vibe.

Gemini

Same split, one structural difference. Gemini doesn't take the system prompt as a top-level argument — it hangs off a GenerateContentConfig as system_instruction, while the user's text rides in contents. The config object is also where the rest of the knobs live, so this is the shape you'll reuse every time you reach for max_output_tokens, temperature, or a response schema later.

"""Week 2 - Prompting fundamentals (Gemini).

Same lesson as the OpenAI example, same question asked twice, so you can
read the two SDKs side by side. The only real difference is where the
system instruction goes: Gemini hangs it off a config object instead of
a top-level argument.
"""
import os
import sys

from dotenv import find_dotenv, load_dotenv
from google import genai
from google.genai import types

load_dotenv(find_dotenv())

if not os.environ.get("GEMINI_API_KEY"):
    sys.exit("GEMINI_API_KEY is not set. Put it in the course-root .env file.")

client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])

QUESTION = "Should I use a vector database for my app?"

# region: bare-call
# No system instruction. The model decides everything about the answer.
bare = client.models.generate_content(
    model="gemini-3.1-flash-lite", contents=QUESTION,
)
# endregion

# region: system-call
# Gemini carries the system prompt on a GenerateContentConfig as
# system_instruction; the user's text stays in `contents`.
SYSTEM = (
    "You are a senior backend engineer. Answer in at most three sentences. "
    "Lead with a direct yes/no/it-depends, then the single deciding factor. "
    "No preamble, no bullet points."
)
guided = client.models.generate_content(
    model="gemini-3.1-flash-lite",
    contents=QUESTION,
    config=types.GenerateContentConfig(system_instruction=SYSTEM),
)
# endregion

print("=== bare prompt ===\n")
print(bare.text)
print("\n=== with a system instruction ===\n")
print(guided.text)

Two SDKs, one idea, and the divergence is purely where the system prompt attaches: a named argument in OpenAI, a config field in Gemini. The prompt text itself is byte-for-byte identical between the two files, which is the point. Prompting is a skill that transfers across providers; the SDK plumbing is the part you look up.

Put it to work

Three small web apps, each one a single docker-compose project under code/showcase/<slug>/. The drill is the same as last week: bash bootstrap-secrets.sh, docker compose up --build, open http://localhost:3000. Each one isolates one prompting move so you can feel it change the output in real time, instead of reading about it.

Showcase 1 — Role control

One question, a dropdown of roles — plain teacher, skeptical engineer, explain-to-a-nine-year-old, bulleted list. The text you type never changes. Only the system prompt behind it does, and the answer reshapes itself every time. It's the fastest way I know to internalize that the system slot isn't decoration; it's the steering wheel.

Showcase 2 — Few-shot

A classification task with a checkbox that flips example cases on and off. With the examples in the prompt, the label set and the one-line format stay locked. Turn them off and the model starts inventing its own labels and wrapping them in apologetic prose. Few-shot isn't magic — it's showing the model the shape of a right answer instead of describing it and hoping.

Showcase 3 — Prompt template

Paste a messy change description, get a release note in the same three sections every time: Summary, Impact, Action. The template lives inside the prompt, so the structure becomes a property of the request rather than something you cross your fingers for. This is the move that turns a model call into something you can put downstream of other code.

All three run the same backend/main.py, byte for byte. It reads PROVIDER and imports either ai_openai or ai_gemini, so PROVIDER=gemini docker compose up --build swaps the SDK without touching anything else. The one design choice that keeps these from being toys is that the prompt does the work — none of them reach past a single model call, and all three are predictable enough to trust because the prompt pinned them down.

Run it

The README in this folder has the Python version, the install line, the two environment variables, and the exact command for the basic examples and each of the three showcases. Keys come from the untracked .env at the course root, same as week one.

Takeaways

The prompt is the interface. You don't get to retrain the model or see its weights, but you get to decide what goes in the system slot, whether you show examples, and whether you hand it a template — and those three decisions cover most of what "prompt engineering" actually means in practice. Be specific in the system prompt: rules, not vibes. Show examples when the format matters more than the explanation. Hand it a template when you need the same structure every time. Everything fancier in the later chapters is built on top of these three, and the model never changed at all.