Skip to content
Go back

Who Actually Runs the Command? How AI Agents Work (Build One in Python)

I typed one command, and an AI wrote my commit message and made the commit. But a language model can’t run commands. So who actually ran git?

To answer that, this post walks through a small agent you can run on your own machine. You don’t need an AI background, only enough Python to read along.

The example is gcommit: it reads your staged changes and commits them with a decent message. The full code is in the git-commit-agent repo.

What a language model can and can’t do

An LLM takes text in and gives text out. That’s the whole interface, and it comes with two big limits. What it produces is text and only text, so it can’t draw a picture, play a sound or send an email. And it has no memory: every request starts from a blank page.

If the model has no memory, how does a chat continue? On every request, the app sends the whole conversation again. The model reads it from the top and carries on. The memory lives in your app, not in the model, and we’ll see that exact list in code below.

The other puzzle is how AI apps create images, check your email or edit photos when the model only produces text. Picture the model as a brain in a jar. It can think, but it has no hands. The hands come from your code: the model asks in text, and your program does the real work.

Tool calling

Modern models support tool calling. Instead of plain text, the model writes a structured request: call this tool with these arguments.

Some providers do run a few tools on their own servers, web search for example. Even then, the model itself only asks.

So what is an agent? A model, some tools, a loop, and a rule for when to stop. Tool calling is what the model brings, and the agent is everything wrapped around it. Think of the model as a smart colleague locked in a room with a slot in the door. Notes pass out through the slot and results pass back in, and nobody in the room touches your computer.

Four ideas follow: what a tool really is, why an agent is a loop, why tool results matter so much, and why your code is the safety boundary.

Set up the project

You need Python 3.12 or newer, git, uv and an OpenRouter account. I’m using uv 0.13.1. Older versions generate a different starter layout, so check yours with uv --version.

An OpenRouter key

OpenRouter gives you one API for lots of different models, using the same request format as OpenAI. That’s why I picked it for this demo.

  1. Create an account at openrouter.ai.
  2. Open the API Keys page and click New Key. I named mine git-commit-agent, set the expiration to one day and left the credit limit at no limit. OpenRouter warns that a key with no limit can spend your whole balance. That’s fine for a one-day demo key on free models; for real projects, set a limit.
  3. Click Create and copy the key right away, because OpenRouter won’t show it again. Treat it like a password.

I want you to be able to run this without buying tokens. OpenRouter has a free router, openrouter/free, which picks one of the free models for each request. Free models have usage limits and differ in how well they handle tool calling, so if a run acts up, run it again or pick a different model. The code uses openrouter/free by default, and one setting, GCOMMIT_MODEL, switches the model.

The project

I’m using uv instead of pip. One tool handles the virtual environment, the dependencies, the lock file and even the Python version, so your setup is easier to repeat on another machine.

uv --version
uv init --package git-commit-agent
cd git-commit-agent
uv venv --seed
touch .env

uv already made a src folder with a starter package named after the project. We’ll replace it with our own package, gcommit, so leave it for now. The --seed flag puts pip in the virtual environment in case another tool expects it.

Add two dependencies:

uv add openai python-dotenv

openai is the official client library. OpenRouter uses the same request format, so we point this client at OpenRouter’s URL and reach any model through it. python-dotenv reads your .env file and loads the key into the environment, so the key never sits in your source code.

Put your key in .env under this exact name:

OPENROUTER_API_KEY=your-key-here

uv creates a .gitignore for the usual Python files, but .env isn’t in it. Add it yourself so the key never lands on GitHub by accident. Anyone with your key can spend your credits.

echo ".env" >> .gitignore

The layout

Delete uv’s starter package, then make a gcommit package inside src with three files:

src/gcommit/__init__.py
src/gcommit/agent.py
src/gcommit/tools.py

Don’t run any uv commands until pyproject.toml is fixed, because the project still expects the starter package. Only two files matter: tools.py holds the hands, and agent.py holds the loop.

In pyproject.toml, change the [project.scripts] section so the command is called gcommit and runs our main function. Then add the build section that tells uv the package folder is named gcommit. Without it, uv looks for the starter package we deleted and the install fails. You can ignore the rest of the file.

[project.scripts]
gcommit = "gcommit.agent:main"

[tool.uv.build-backend]
module-name = "gcommit"

If you’d rather skip the typing, clone the finished project. It’s the same code, and gcommit is the command name used throughout, so rename the folder as you clone:

git clone https://github.com/nononsense-ai/git-commit-agent.git gcommit
cd gcommit
uv sync

Idea 1: a tool is a name, a description and a schema

Here is the tool list from tools.py. The first tool is git_diff, and that name is how the model refers to it.

available_tools: list[ChatCompletionFunctionToolParam] = [
    {
        "type": "function",
        "function": {
            "name": "git_diff",
            "description": (
                "Returns a git diff for the current repo. "
                "By default returns the staged diff. "
                "Pass 'path' to see the diff of a single file, which is useful "
                "when the full diff is too large or has been truncated."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "staged": {
                        "type": "boolean",
                        "description": "If true (default), diff staged changes. If false, diff unstaged changes",
                    },
                    "path": {
                        "type": "string",
                        "description": "Optional. Relative path of a single file to diff",
                    },
                },
                "required": [],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "git_commit",
            "description": "Creates a commit with a given message",
            "parameters": {
                "type": "object",
                "properties": {
                    "message": {"type": "string", "description": "The commit message"}
                },
                "required": ["message"],
            },
        },
    },
]

The description does more work than it looks like. It’s a small prompt that teaches the model when to reach for the tool. See the hint about using path when the diff is too large? That one sentence changes what the model does.

The schema lists the arguments. git_diff takes two: staged, true or false, and path, which is text. JSON Schema is the standard way to describe the shape of JSON data. The required list says what the model has to send. The diff tool needs nothing, while the commit tool always needs a message.

Each provider wraps the schema a little differently. Anthropic calls it input_schema, OpenAI puts it under function.parameters, and Gemini uses function declarations. The JSON Schema inside is the same.

There’s also MCP, short for Model Context Protocol, the closest thing to a shared standard for describing tools across vendors. We won’t use it today, but you’ll run into it.

The hands: ordinary Python functions

Each tool is backed by a plain Python function. The diff function builds a git diff command and runs it with subprocess. If staged is true it adds --staged, and if there’s a path it limits the diff to that file.

def run_git_diff(staged: bool = True, path: Optional[str] = None) -> str:
    cmd = ["git", "diff"]

    if staged:
        cmd.append("--staged")

    if path:
        cmd += ["--", path]

    result = _run_git(cmd)
    if isinstance(result, str):
        return result
    if result.returncode != 0:
        return f"ERROR (exit {result.returncode}): {result.stderr or result.stdout}"
    if not result.stdout:
        return "OK: (no changes)"

    diff = result.stdout
    if len(diff) > MAX_DIFF_CHARS:
        # Truncating is what gives the agent a real reason to loop:
        # it has to call git_diff again, one file at a time.
        return (
            f"OK: {diff[:MAX_DIFF_CHARS]}\n"
            f"...(TRUNCATED at {MAX_DIFF_CHARS} chars. The diff is incomplete. "
            "Do NOT commit yet. Call git_diff again with 'path' for each file "
            "you have not seen.)"
        )
    return f"OK: {diff}"

The function cuts big diffs at 10,000 characters and adds a note telling the model how to get the rest: call the tool again with a path. That note is why the agent sometimes needs several steps.

The commit function does one thing. It runs git commit with the model’s message and reports what happened.

The last piece of tools.py is a dictionary listing every tool the model may use. If a tool isn’t in it, it can’t run, and that matters in the safety section.

DISPATCH = {"git_diff": run_git_diff, "git_commit": run_git_commit}

Idea 2: an agent is a loop, not a pipeline

In a pipeline you pick every step ahead of time. In an agent the model picks the next step and decides when to stop, and your code keeps the loop turning. A plain script would handle a small change just as well. The loop earns its place when the diff is big and the model has to inspect files one at a time, which you’ll see in the demo.

Open agent.py. We start a list of messages: a system message that sets the job and a user message with the task.

SYSTEM_PROMPT = (
    "You write commit messages. Inspect the diff first, "
    "follow conventional commits, never push. "
    "If a diff result says it was TRUNCATED, call git_diff again with "
    "'path' for every file in the diff, one at a time, before you commit."
)
USER_PROMPT = "Commit the staged changes with a good message"

# The messages list IS the agent's state. The model has no memory;
# we resend this whole list on every iteration.
messages: list[ChatCompletionMessageParam] = [
    {"role": "system", "content": SYSTEM_PROMPT},
    {"role": "user", "content": USER_PROMPT},
]

Remember the memory question from the start? This is the answer in code. The model has no memory, so every turn we send the whole list again. That makes the list the agent’s state, and the only reason the model knows what already happened.

The prompt says “never push”, but a prompt is only a request. I’ll come back to that in the safety section.

Here is the loop:

for step in range(1, max_steps + 1):
    response = client.chat.completions.create(
        model=model, messages=messages, tools=available_tools
    )
    msg = response.choices[0].message

    # No tool call means the model is done: that is the stop condition.
    if not msg.tool_calls:
        print("Agent:", msg.content)
        break

    messages.append(
        cast(ChatCompletionAssistantMessageParam, msg.model_dump(exclude_none=True))
    )

    for call in msg.tool_calls:
        result = execute_tool(call.function.name, call.function.arguments)
        messages.append(
            {"role": "tool", "tool_call_id": call.id, "content": result}
        )
else:
    print(f"Stopped: hit step limit ({max_steps})")

(This is trimmed from the real file for reading; the repo version also logs each step and handles unsupported tool-call types.) In order:

Models slip up. They ask for tools that don’t exist or send broken JSON, so execute_tool catches those cases and sends back an ERROR the model can react to.

def execute_tool(name: str, raw_arguments: str) -> str:
    """Run one tool requested by the model. Always returns an OK:/ERROR: string."""
    if name not in DISPATCH:
        return f"ERROR: unknown tool '{name}'"
    try:
        args = json.loads(raw_arguments or "{}")
    except json.JSONDecodeError:
        return "ERROR: arguments were not valid JSON"
    if not isinstance(args, dict):
        return "ERROR: arguments must be a JSON object"
    try:
        return DISPATCH[name](**args)
    except TypeError as e:
        return f"ERROR: bad arguments: {e}"

The whole loop in one sentence: send the messages, get an answer, and if there’s a tool call, run it, add the result and go around again. No tool call means we’re done.

Idea 3: tool results are the model’s only view of reality

The model can’t see your terminal. The text your functions return, like the execute_tool result above, is the only thing it knows about the world.

That’s why every result starts with OK or ERROR. A vague result makes the model guess; a clear ERROR with a reason lets it correct course.

Demo

Install the command. The editable flag puts gcommit on your path, so you can run it from any git repo, and it keeps finding the .env file in this project.

uv tool install --editable .

If your terminal says gcommit isn’t found, run uv tool update-shell, then open a new terminal window.

We need a repo to commit to. uv init in a fresh folder makes a tiny project and runs git init for us. Stage everything it created; that’s our first change. Then run gcommit with the verbose flag so we can watch each step:

gcommit -v
  1. The model asks for the diff. It doesn’t guess at the changes, because our prompt told it to inspect first. Our code ran git and returned the diff. Watch the list grow: the system message, the user message, the model’s request, then our result. Every pass through the loop adds to it.
  2. It proposes a message and asks to commit. Before git runs, our code stops and asks me. I read the message and type y. The commit goes through and the model confirms it. A handful of steps, start to finish.
  3. With nothing staged, the tool reports no changes, and the model has to read that result and respond to it.
  4. To test a rejection, I add a small function to the starter file, stage it and run gcommit again, but this time I say no. Our code returns an error saying the user said no, and the model can read that and propose another message.
  5. For the last demo, I make a change across a couple of large files. The first diff gets cut off, so the model asks for files one at a time. The number of steps depends on what it finds.

Idea 4: your code is the safety boundary

Remember the “never push” line in the prompt? That was a request. What actually prevents a push is that no push tool exists, and the model can’t ask for something that isn’t there.

There are four safeguards, and none of them lives in the prompt:

def run_git_commit(message: str) -> str:
    # Human approval gate: the harness, not the model, has the final say.
    print(f"\nProposed commit message:\n\n{message}\n")
    answer = input("Proceed with this commit? [y/N] ").strip().lower()
    if answer != "y":
        return (
            "ERROR: the user rejected this commit message. "
            "Do not retry the same message. Propose a different one or stop."
        )
    ...

None of these depends on the model behaving well. They all live in your code.

Recap

An agent is a model with tools and a loop, with your code in charge of what actually runs.

  1. A tool is a name, a description and a schema.
  2. An agent is a loop.
  3. Tool results are the model’s only view of reality.
  4. Your code is the safety boundary.

So who ran the git command? Your code did. The model only asked.

Try it yourself

Make an OpenRouter account, create a key, put it in your .env file, install with uv and run it. Then take the challenge: add a third tool, git_status. You need a schema in available_tools, a function that runs git status, and one line in DISPATCH.

Next videos could run this same agent on another provider, turn the tools into an MCP server, or look at several agents working together. Tell me in the comments which you’d watch first.

If you want to see typed, fast model outputs from a different angle, read the Jev tutorial and the Clef in Python post.


Share this post:

Previous Post
Jev by TypeSafe AI: Typed Answers With Confidence Scores
Next Post
Cloudflare Clef in Python: Typed AI Answers in Milliseconds