AI Agents: From LLM Calls To Loops

An agent is just a model call inside a loop. Write that loop by hand once and every framework gets a lot less mysterious.

A couple of years ago, if you said “agent” at dinner, people pictured a spy in sunglasses. Now LLMReferenceLarge language model You send it text, it sends text back. It’s great at writing and knows a lot about the world, but nothing about your shop., agent and tool (the AI kind, not the one in your garage) are everywhere. The models got good enough to pick the right function to call, so they can take a few steps on their own. But everyone means something a bit different by “agent”. So let’s pin it down.

An agentReferenceagent LLM calls in a loop, working toward a goal. The model decides what to do next; your code does it and keeps the loop going. is LLM calls inside a loop, working toward a goal, and that goal usually means doing things: checking a database, looking up a price. But the model has no superpower to press buttons in your software. It reads text as tokensReferencetokens Small chunks of text, usually a word or a piece of one. Models read and write in tokens, and you pay per token too. and writes text back, one token at a time. That’s all it can do.

The doing happens in your code. You write normal functions and tell the model they exist. When it needs one, it replies “please run this, with these inputs”, and your code runs it and sends back the result. Those functions are toolsReferencetools Normal functions in your code that the model is allowed to ask for, like check_stock. The model never sees the code, only the name, a description and the arguments it takes.. Less superhero, more very smart pen pal.

To see that loop from the inside, I built a corner store assistant ↗: a terminal chat for a small shop that answers questions about stock and prices. In this post I rebuild it by hand, starting from one API callReferenceAPI call One request to the model over the internet. One request in, one reply out, and each one costs a little., and then again with a frameworkReferenceframework A library that writes the loop for you, so you only describe the agent and its tools., so you can see how much work the framework is doing for you.

Here’s the difference in one picture:

One call answers once, with whatever the model already knows. An agent keeps calling the model, feeding it tool results each time, until it has a real answer for you.

That’s the whole idea: LLM calls in a loop, going after a goal. Everything else in this post is details.

Every snippet comes in Python, TypeScript and C#. Pick a language on any code card and the rest of the page switches with it.

The store sells five products, and a customer asks one question: Do you have milk, and how much is a bottle? The model has never seen this shop. It doesn’t know the stock or the prices, so the only honest way to answer is to look them up.

A single call

Let’s start with one request. A list of messagesReferencemessages Each message has a role. system is the rules for the model, user is the person asking, assistant is the model’s own replies and tool is the result of a tool call. goes in, one message comes out.

Language
from openai import OpenAI

client = OpenAI()

# One request: a list of messages goes in, one message comes back.
response = client.chat.completions.create(
    model="gpt-5.4-mini",
    messages=[{"role": "user", "content": "Do you have milk, and how much is a bottle?"}],
)
print(response.choices[0].message.content)
import OpenAI from "openai";

const client = new OpenAI();

// One request: a list of messages goes in, one message comes back.
const response = await client.chat.completions.create({
  model: "gpt-5.4-mini",
  messages: [{ role: "user", content: "Do you have milk, and how much is a bottle?" }],
});
console.log(response.choices[0].message.content);
using OpenAI.Chat;

ChatClient client = new("gpt-5.4-mini", Environment.GetEnvironmentVariable("OPENAI_API_KEY"));

// One request: a list of messages goes in, one message comes back.
ChatCompletion completion = client.CompleteChat("Do you have milk, and how much is a bottle?");
Console.WriteLine(completion.Content[0].Text);
What the code prints
User

Do you have milk, and how much is a bottle?

Model

I can’t see your shop’s shelves or prices, so I can’t say. Check your stock list or ask the shop owner.

End of the conversation. The model forgets all of it. The next message starts from zero.

That’s the whole interface. The model is statelessReferencestateless It keeps nothing between requests. Every call starts from zero, so anything the model should know has to be inside that request.Read more ↗: it remembers nothing between calls, so a “chat” is really your code sending the whole conversation again every time. Ask it about the milk and it can only guess, or tell you it can’t see the shelves. Fair enough. It has never been to the shop.

Giving the model tools

A tool is a normal function plus a description the model can read. The function stays in your code. The model never runs anything itself: it replies with “please call this function with these arguments”, and your code decides whether to do it.

Language
INVENTORY = {
    "milk": {"price": 7.00, "stock": 12},
    "bread": {"price": 4.50, "stock": 0},
    "eggs": {"price": 12.00, "stock": 8},
    "apples": {"price": 3.00, "stock": 30},
    "coffee": {"price": 25.00, "stock": 5},
}


# The tools are normal functions. No AI in here.
def check_stock(product_name: str) -> str:
    return f"{INVENTORY[product_name]['stock']} units in stock"


def get_price(product_name: str) -> str:
    return f"{INVENTORY[product_name]['price']:.2f} AED"


# Lets the loop find a function from the name the model sends.
TOOL_FUNCTIONS = {"check_stock": check_stock, "get_price": get_price}


# The name and description are what the model reads.
def product_tool(name: str, description: str) -> dict:
    return {
        "type": "function",
        "function": {
            "name": name,
            "description": description,
            "parameters": {
                "type": "object",
                "properties": {"product_name": {"type": "string", "description": "e.g. 'milk'"}},
                "required": ["product_name"],
            },
        },
    }


TOOLS = [
    product_tool("check_stock", "Check how many units of a product are in stock."),
    product_tool("get_price", "Get the unit price of a product, in AED."),
]
import OpenAI from "openai";

const INVENTORY: Record<string, { price: number; stock: number }> = {
  milk: { price: 7.0, stock: 12 },
  bread: { price: 4.5, stock: 0 },
  eggs: { price: 12.0, stock: 8 },
  apples: { price: 3.0, stock: 30 },
  coffee: { price: 25.0, stock: 5 },
};

// The tools are normal functions. No AI in here.
function checkStock(productName: string): string {
  return `${INVENTORY[productName].stock} units in stock`;
}

function getPrice(productName: string): string {
  return `${INVENTORY[productName].price.toFixed(2)} AED`;
}

// Lets the loop find a function from the name the model sends.
const toolFunctions: Record<string, (args: { product_name: string }) => string> = {
  check_stock: ({ product_name }) => checkStock(product_name),
  get_price: ({ product_name }) => getPrice(product_name),
};

// The name and description are what the model reads.
function productTool(name: string, description: string): OpenAI.ChatCompletionTool {
  return {
    type: "function",
    function: {
      name,
      description,
      parameters: {
        type: "object",
        properties: { product_name: { type: "string", description: "e.g. 'milk'" } },
        required: ["product_name"],
      },
    },
  };
}

const tools = [
  productTool("check_stock", "Check how many units of a product are in stock."),
  productTool("get_price", "Get the unit price of a product, in AED."),
];
using System.Text.Json;
using OpenAI.Chat;

Dictionary<string, (decimal Price, int Stock)> inventory = new()
{
    ["milk"] = (7.00m, 12),
    ["bread"] = (4.50m, 0),
    ["eggs"] = (12.00m, 8),
    ["apples"] = (3.00m, 30),
    ["coffee"] = (25.00m, 5),
};

// The tools are normal functions. No AI in here.
string CheckStock(string productName) => $"{inventory[productName].Stock} units in stock";

string GetPrice(string productName) => $"{inventory[productName].Price:F2} AED";

// Lets the loop find a function from the name the model sends.
Dictionary<string, Func<JsonElement, string>> toolFunctions = new()
{
    ["check_stock"] = args => CheckStock(args.GetProperty("product_name").GetString()!),
    ["get_price"] = args => GetPrice(args.GetProperty("product_name").GetString()!),
};

// The name and description are what the model reads.
static ChatTool ProductTool(string name, string description) => ChatTool.CreateFunctionTool(
    functionName: name,
    functionDescription: description,
    functionParameters: BinaryData.FromString("""
        {
          "type": "object",
          "properties": { "product_name": { "type": "string", "description": "e.g. 'milk'" } },
          "required": ["product_name"]
        }
        """));

ChatCompletionOptions options = new()
{
    Tools =
    {
        ProductTool("check_stock", "Check how many units of a product are in stock."),
        ProductTool("get_price", "Get the unit price of a product, in AED."),
    },
};

The name, the description and the JSON schemaReferenceJSON Schema A JSON document that describes the shape of other JSON: which fields exist, their types and which ones are required. Here it tells the model what arguments a tool takes.Read more ↗ are all the model ever sees of your function, so write them like documentation. Both tools take one product name, so a small helper builds both schemas.

The map from tool name to function is the dispatch tableReferencedispatch table A lookup from a name to the function that handles it. Instead of a long chain of ifs, the code finds the function by its name and calls it.Read more ↗. When the model asks for get_price, the loop looks the name up there.

To keep the code short, this post follows the happy path: the product always exists and the arguments are always right. A real shop would check both.

The loop

Now put the call in a loop. Each turn sends the conversation and the tools. If the reply has tool callsReferenceTool call The model’s way of asking. Instead of text for the person, it replies “run check_stock with milk”, and your code runs the function., the loop runs them, adds the resultsReferenceTool result Whatever the function returned, sent back to the model as a new message so it can keep going. to the conversation and goes around again. If the reply is plain text, that’s the answer and we’re done.

You’ll also see a MAX_STEPS limit in the code. The model decides when to stop, and every round is a paid request, so a model that keeps asking for tools could loop (and bill you) forever. The limit is a safety brake: after 10 rounds the loop gives up. It’s the one safeguard I kept in the code.

The other new thing is SYSTEM_PROMPT. It’s the system promptReferencesystem prompt A message at the very start of the conversation with the rules for the model: who it is, how to talk and what to do. The person chatting never sees it., the first message the model reads, and it tells the model how to behave. Ours says: you work for a corner store, keep answers short, and never state a price or a stock level from memory. That last rule matters. A model that can guess a price will happily guess one, and the rule pushes it to call a tool instead. Since the model remembers nothing between calls, the prompt goes at the top of the list every time.

Language
import json

SYSTEM_PROMPT = (
    "You are a friendly assistant for a small corner store. Keep answers short. "
    "Never state a price or a stock level from memory: use the tools."
)
MAX_STEPS = 10


def run(question: str) -> str:
    # The conversation so far: the rules first, then the question.
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": question},
    ]
    # The brake: give up after MAX_STEPS rounds.
    for _ in range(MAX_STEPS):
        response = client.chat.completions.create(
            model="gpt-5.4-mini",
            messages=messages,
            tools=TOOLS,
        )
        message = response.choices[0].message
        # No tool calls means plain text: that is the answer.
        if not message.tool_calls:
            return message.content
        # Keep the model's request in the history, before the results.
        messages.append(message)
        # Run each tool the model asked for and add its result.
        for call in message.tool_calls:
            name = call.function.name
            arguments = json.loads(call.function.arguments)
            result = TOOL_FUNCTIONS[name](**arguments)
            # The id tells the model which request this result answers.
            messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
    return f"Stopped after {MAX_STEPS} steps."


print(run("Do you have milk, and how much is a bottle?"))
const SYSTEM_PROMPT =
  "You are a friendly assistant for a small corner store. Keep answers short. " +
  "Never state a price or a stock level from memory: use the tools.";
const MAX_STEPS = 10;

async function run(question: string) {
  // The conversation so far: the rules first, then the question.
  const messages: OpenAI.ChatCompletionMessageParam[] = [
    { role: "system", content: SYSTEM_PROMPT },
    { role: "user", content: question },
  ];
  // The brake: give up after MAX_STEPS rounds.
  for (let step = 0; step < MAX_STEPS; step++) {
    const response = await client.chat.completions.create({
      model: "gpt-5.4-mini",
      messages,
      tools,
    });
    const message = response.choices[0].message;
    // No tool calls means plain text: that is the answer.
    if (!message.tool_calls) return message.content;
    // Keep the model's request in the history, before the results.
    messages.push(message);
    // Run each tool the model asked for and add its result.
    for (const call of message.tool_calls) {
      if (call.type !== "function") continue;
      const name = call.function.name;
      const args = JSON.parse(call.function.arguments);
      const result = toolFunctions[name](args);
      // The id tells the model which request this result answers.
      messages.push({ role: "tool", tool_call_id: call.id, content: result });
    }
  }
  return `Stopped after ${MAX_STEPS} steps.`;
}

console.log(await run("Do you have milk, and how much is a bottle?"));
const string SystemPrompt =
    "You are a friendly assistant for a small corner store. Keep answers short. " +
    "Never state a price or a stock level from memory: use the tools.";
const int MaxSteps = 10;

async Task<string> RunAsync(string question)
{
    // The conversation so far: the rules first, then the question.
    List<ChatMessage> messages = [new SystemChatMessage(SystemPrompt), new UserChatMessage(question)];
    // The brake: give up after MaxSteps rounds.
    for (int step = 0; step < MaxSteps; step++)
    {
        ChatCompletion completion = await client.CompleteChatAsync(messages, options);
        // No tool calls means plain text: that is the answer.
        if (completion.ToolCalls.Count == 0) return completion.Content[0].Text;
        // Keep the model's request in the history, before the results.
        messages.Add(new AssistantChatMessage(completion));
        // Run each tool the model asked for and add its result.
        foreach (ChatToolCall call in completion.ToolCalls)
        {
            using JsonDocument args = JsonDocument.Parse(call.FunctionArguments);
            string result = toolFunctions[call.FunctionName](args.RootElement);
            // The id tells the model which request this result answers.
            messages.Add(new ToolChatMessage(call.Id, result));
        }
    }
    return $"Stopped after {MaxSteps} steps.";
}

Console.WriteLine(await RunAsync("Do you have milk, and how much is a bottle?"));

Let’s follow the milk question. On the first turn the model can ask for both facts at once, two tool calls in one reply: check_stock("milk") and get_price("milk"). The loop runs both, adds the two results and calls the model again. On the second turn the model has 12 units and 7.00 AED right there in the conversation, so it answers in text and the loop ends. If it asks for the facts one at a time instead, it just takes one more turn.

Every call sends the whole conversation so far. The tool results become new messages, so the second call has everything the model needs to answer.

The model decides what happens next. Your code keeps going until the model says it’s done.

That’s the difference between an agent and a workflowReferenceworkflow A fixed series of steps written in your code. The model can fill in a step, but it never decides which step comes next.. In a workflow, your code fixes the order of the steps and the model fills each one in. In an agent, the model picks the next step, and your code just enforces the rules.

The workflow runs the same steps in the same order every time. The agent picks its next step on each turn, so two runs of the same question can go in a different order.

I used Chat CompletionsReferenceChat Completions OpenAI’s classic API for talking to its models: you send the list of messages, it sends one message back. Lots of other providers copy its shape.Read more ↗ here because the growing list of messages makes the loop easy to see. OpenAI recommends the Responses APIReferenceResponses API OpenAI’s newer API for talking to its models. It can keep the conversation on OpenAI’s side and comes with built-in tools like web search. Chat Completions is the older, simpler one. for new projects, and some newer models ↗ only support tool calling there. The loop looks the same in both.

The details that keep it working

The loop is short, but there are four details that are easy to get wrong.

  • Order matters. The assistant message with the tool calls goes into the history before their results. Each result is a tool message with the tool_call_id of the call it answers. Every call needs exactly one answer, or the next request gets rejected.
  • One reply can have several calls. Run all of them before you call the model again.
  • Errors are results too. The code above skips this to stay short, but a real loop wraps each tool call. An unknown tool, bad arguments, an exceptionReferenceexception The error a function throws when something goes wrong. If nobody catches it, the program stops. inside the function: send it back as the tool result. The model reads the error and can fix its own mistake. If you crash the loop instead, it never gets the chance.
  • The step limit is the brake. The model decides when to stop, so you need a limit for when it doesn’t. Every step is a paid request, and the model isn’t the one paying.

The arguments also arrive as a JSONReferenceJSON A plain text format for data: curly braces, keys in quotes, values after colons. Every language can read it, which is why APIs love it.Read more ↗ string, so they need parsing before the function sees them. That’s the json.loads in the loop, and the same step in the other two languages.

The same agent with a framework

Agent frameworks are basically this loop in a box. The corner store uses the OpenAI Agents SDKReferenceOpenAI Agents SDK OpenAI’s agent framework. SDK means software development kit: the official library a company ships for its platform.Read more ↗, which exists for Python and TypeScript. OpenAI doesn’t have a .NETReference.NET Microsoft’s platform for C# and friends. When someone says “a .NET version”, they mean one you can use from C#.Read more ↗ version, so the C# tab uses Microsoft Agent Framework ↗, which reached 1.0 in April 2026 and works with the same OpenAI client.

Language
from agents import Agent, Runner, function_tool

INVENTORY = {
    "milk": {"price": 7.00, "stock": 12},
    "bread": {"price": 4.50, "stock": 0},
    "eggs": {"price": 12.00, "stock": 8},
    "apples": {"price": 3.00, "stock": 30},
    "coffee": {"price": 25.00, "stock": 5},
}


# The decorator turns a plain function into a tool.
@function_tool
def check_stock(product_name: str) -> str:
    """Check how many units of a product are in stock."""
    return f"{INVENTORY[product_name]['stock']} units in stock"


@function_tool
def get_price(product_name: str) -> str:
    """Get the unit price of a product, in AED."""
    return f"{INVENTORY[product_name]['price']:.2f} AED"


# The agent: its rules, its model and its tools.
agent = Agent(
    name="Corner Store Assistant",
    instructions=(
        "You are a friendly assistant for a small corner store. Keep answers short. "
        "Never state a price or a stock level from memory: use the tools."
    ),
    model="gpt-5.4-mini",
    tools=[check_stock, get_price],
)

# The framework runs the whole loop, with the same 10-round brake.
result = Runner.run_sync(agent, "Do you have milk, and how much is a bottle?", max_turns=10)
print(result.final_output)
import { Agent, run, tool } from "@openai/agents";
import { z } from "zod";

const INVENTORY: Record<string, { price: number; stock: number }> = {
  milk: { price: 7.0, stock: 12 },
  bread: { price: 4.5, stock: 0 },
  eggs: { price: 12.0, stock: 8 },
  apples: { price: 3.0, stock: 30 },
  coffee: { price: 25.0, stock: 5 },
};

// tool() turns a function into a tool, schema included.
const checkStock = tool({
  name: "check_stock",
  description: "Check how many units of a product are in stock.",
  parameters: z.object({ product_name: z.string() }),
  execute: ({ product_name }) => {
    return `${INVENTORY[product_name].stock} units in stock`;
  },
});

const getPrice = tool({
  name: "get_price",
  description: "Get the unit price of a product, in AED.",
  parameters: z.object({ product_name: z.string() }),
  execute: ({ product_name }) => {
    return `${INVENTORY[product_name].price.toFixed(2)} AED`;
  },
});

// The agent: its rules, its model and its tools.
const agent = new Agent({
  name: "Corner Store Assistant",
  instructions:
    "You are a friendly assistant for a small corner store. Keep answers short. " +
    "Never state a price or a stock level from memory: use the tools.",
  model: "gpt-5.4-mini",
  tools: [checkStock, getPrice],
});

// The framework runs the whole loop, with the same 10-round brake.
const result = await run(agent, "Do you have milk, and how much is a bottle?", { maxTurns: 10 });
console.log(result.finalOutput);
using System.ComponentModel;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Chat;

Dictionary<string, (decimal Price, int Stock)> inventory = new()
{
    ["milk"] = (7.00m, 12),
    ["bread"] = (4.50m, 0),
    ["eggs"] = (12.00m, 8),
    ["apples"] = (3.00m, 30),
    ["coffee"] = (25.00m, 5),
};

// The attribute and the signature become the tool's description and schema.
[Description("Check how many units of a product are in stock.")]
string CheckStock(string productName) => $"{inventory[productName].Stock} units in stock";

[Description("Get the unit price of a product, in AED.")]
string GetPrice(string productName) => $"{inventory[productName].Price:F2} AED";

// The agent: its rules, its model and its tools.
AIAgent agent = new ChatClient("gpt-5.4-mini", Environment.GetEnvironmentVariable("OPENAI_API_KEY"))
    .AsAIAgent(
        name: "Corner Store Assistant",
        instructions: "You are a friendly assistant for a small corner store. Keep answers short. " +
            "Never state a price or a stock level from memory: use the tools.",
        tools:
        [
            AIFunctionFactory.Create(CheckStock, name: "check_stock"),
            AIFunctionFactory.Create(GetPrice, name: "get_price"),
        ],
        clientFactory: chat => chat.AsBuilder()
            .UseFunctionInvocation(configure: f => f.MaximumIterationsPerRequest = 10)
            .Build());

// The framework runs the whole loop, with the same 10-round brake.
Console.WriteLine(await agent.RunAsync("Do you have milk, and how much is a bottle?"));

The tools are just functions now. The framework reads each signatureReferencesignature The first line of a function: its name, its parameters and their types. Everything except the body. to build the schema: type hintsReferencetype hints Labels in Python code that say what type a value should be, like name: str. Python itself ignores them, but tools can read them.Read more ↗ and the docstringReferencedocstring The text in quotes right under a Python function’s first line, describing what it does. Here it becomes the tool’s description.Read more ↗ in Python (finally, someone reads the docstrings), the zodReferenceZod A TypeScript library for describing the shape of your data and checking it at runtime. The Agents SDK turns a zod schema into the JSON schema the model sees.Read more ↗ schema in TypeScript, the parameter types and [Description] attributesReferenceattributes Tags in square brackets that you put on C# code. They don’t do anything themselves, but other code can read them, like a framework looking for a description.Read more ↗ in C#. One side effect: the C# parameter is called productName, so that’s the name the model sees. The dispatch table, the message bookkeeping, the argument parsing and the loop itself are gone, and the step limit is a single option.

Memory across turns

Remember, the model has no memory. So where does a chat get one? From your code. The “memory” is a plain list of messages that you keep and send again on every call. Nothing more.

A chat is the same loop run once per message, with that list growing each time. A follow-up like how much for 3 of them? only works because the list carries the earlier tool results. The model finds 7.00 AED in the conversation and doesn’t need to ask again.

Language
from agents import Runner

history = []
while True:
    question = input("You: ")
    if question == "exit":
        break

    history.append({"role": "user", "content": question})
    result = Runner.run_sync(agent, history)
    print("Agent:", result.final_output)

    # The result carries the whole conversation, ready for the next question.
    history = result.to_input_list()
import { run, user, type AgentInputItem } from "@openai/agents";
import { createInterface } from "node:readline/promises";

const rl = createInterface({ input: process.stdin, output: process.stdout });
let history: AgentInputItem[] = [];

while (true) {
  const question = await rl.question("You: ");
  if (question === "exit") break;

  const result = await run(agent, [...history, user(question)]);
  console.log("Agent:", result.finalOutput);

  // The result carries the whole conversation, ready for the next question.
  history = result.history;
}
using Microsoft.Agents.AI;

// The session remembers the conversation between questions.
AgentSession session = await agent.CreateSessionAsync();

while (true)
{
    Console.Write("You: ");
    string? question = Console.ReadLine();
    if (question is null or "exit") break;

    var response = await agent.RunAsync(question, session);
    Console.WriteLine($"Agent: {response}");
}

Each framework hands the history back in its own way. In Python, to_input_list() returns every item of the run, tool calls and results included, and you add the next user message yourself. TypeScript does the same with result.history and the user() helper. In C#, an AgentSession keeps the history for you, so the loop never touches it. In the corner store the history is a plain list that lives as long as the script. The exercise brief ↗ also describes a SQLiteReferenceSQLite A small database that lives in a single file. No server to run, which is perfect for a corner store.Read more ↗ version that survives a restart.

One thing to watch: the list only grows, and every call pays for all of it. Long chats get slower and cost more, and one day the list no longer fits in the model’s context windowReferencecontext window How much text the model can read in one call. Everything you send, the whole history included, has to fit inside it.. Real apps trim old messages or squash them into a summary. A corner store chat never gets that long.

Streaming and tracing

A run can take a few seconds while the tools work, and staring at a blank terminal is no fun. So the corner store streamsReferenceStreaming Sending the answer in small pieces as the model writes it, instead of all at once when it’s done. It’s why chat apps look like they’re typing.: the answer shows up word by word, and a [tool] line appears each time the model asks for a tool.

Language
import asyncio
from agents import Runner, trace

async def main():
    history = []
    while True:
        question = input("You: ")
        if question == "exit":
            break

        history.append({"role": "user", "content": question})

        # trace() groups everything this turn does in the OpenAI dashboard.
        with trace("Corner Store Assistant"):
            result = Runner.run_streamed(agent, history)

            async for event in result.stream_events():
                if event.type == "raw_response_event":
                    # A piece of the answer: print it right away.
                    if event.data.type == "response.output_text.delta":
                        print(event.data.delta, end="", flush=True)

                elif event.type == "run_item_stream_event":
                    # The model asked for a tool: show which one.
                    if event.item.type == "tool_call_item":
                        print(f"\n[tool] {event.item.raw_item.name}")

        print()
        history = result.to_input_list()

asyncio.run(main())
import { run, user, withTrace, type AgentInputItem } from "@openai/agents";
import { createInterface } from "node:readline/promises";

const rl = createInterface({ input: process.stdin, output: process.stdout });
let history: AgentInputItem[] = [];

while (true) {
  const question = await rl.question("You: ");
  if (question === "exit") break;

  // withTrace() groups everything this turn does in the OpenAI dashboard.
  await withTrace("Corner Store Assistant", async () => {
    const stream = await run(agent, [...history, user(question)], { stream: true });

    for await (const event of stream) {
      if (event.type === "raw_model_stream_event") {
        // A piece of the answer: print it right away.
        if (event.data.type === "output_text_delta") {
          process.stdout.write(event.data.delta);
        }
      }

      if (event.type === "run_item_stream_event") {
        // The model asked for a tool: show which one.
        const call = event.item.rawItem;
        if (event.item.type === "tool_call_item" && call.type === "function_call") {
          console.log(`\n[tool] ${call.name}`);
        }
      }
    }

    await stream.completed;
    console.log();
    history = stream.history;
  });
}
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

AgentSession session = await agent.CreateSessionAsync();

while (true)
{
    Console.Write("You: ");
    string? question = Console.ReadLine();
    if (question is null or "exit") break;

    await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(question, session))
    {
        // A piece of the answer: print it right away.
        Console.Write(update.Text);

        // The model asked for a tool: show which one.
        foreach (AIContent content in update.Contents)
        {
            if (content is FunctionCallContent call)
                Console.WriteLine($"\n[tool] {call.Name}");
        }
    }

    Console.WriteLine();
}

Two kinds of events come in. Text deltasReferenceText deltas The small chunks of text a streaming response sends while the model is still writing. Glue them together in order and you get the full answer. are pieces of the answer. Run itemsReferenceRun items The Agents SDK’s name for everything that happens during a run besides the text: tool calls, tool results, handoffs. are the tool activity, like the model asking for a tool.

The trace block is the other half. Each turn becomes a traceReferencetrace A record of everything one run did, in order and with timings: each model call, each tool call, what went in and what came out. Great for finding out why the agent did something weird. in the OpenAI dashboard, so you can open one and see every model call and tool call it made. Agent Framework traces through OpenTelemetryReferenceOpenTelemetry An open standard for traces, metrics and logs. Your app reports what it did as spans, little timed blocks of work, and any compatible tool can show them.Read more ↗ instead: UseOpenTelemetry() on the agent gives you a span for the agent, each model call and each tool.

What the framework decides for you

Fewer lines also means someone else made some choices for you. These are the ones worth knowing.

  • Turn limits. The Agents SDK throws an error when it hits the limit. Agent Framework, at the limit, makes one last request without tools, so you still get a text answer.
  • Unknown tools. Both Agents SDKs stop with an error by default. Setting tool_not_found_behavior in Python or toolNotFoundBehavior in TypeScript to "return_error_to_model" sends the error back to the model instead. Agent Framework already does that.
  • Exceptions. The frameworks send the model a generic error message and keep the details to themselves. Safer, but harder to debug.
  • Tracing. The Agents SDKs send a trace of every run to OpenAI by default. OPENAI_AGENTS_DISABLE_TRACING=1 turns it off.
  • The API underneath. The Agents SDKs use the Responses API, so their requests aren’t the same as the ones the hand-written loop sends. Agent Framework, built on a ChatClient, still uses Chat Completions.

My advice after building both: write the loop by hand once, just to understand it. Then use a framework, and read its defaults like you would read your own loop. When the framework does something weird, the loop section above is what’s happening underneath.

The corner store didn’t stop at one agent, by the way. Its second phase splits the assistant into a team: writers running in parallel, specialists called as tools and a checkout reached through a handoffReferencehandoff One agent passing the whole conversation to another agent, which takes over from there. Like being transferred to a different department, minus the hold music.. The full code for both phases is in the repository ↗.

Written by

Mikael Ribeiro Simoes

I build internal platforms at ACTVET in Abu Dhabi and coach the UAE’s software competitors for EmiratesSkills and WorldSkills. Before that I won gold at WorldSkills Americas, so now I get to be the one writing the hard tasks. Outside work you’ll find me camping in the desert, at the gym, or at home with my orange cat.