Your first agent#

You will learn: how to create a client, run more than one turn, and what keeps the conversation between them. Prerequisites: Install Zett Agent. No API credentials are needed.

This tutorial uses a scripted model so you can check the result on any machine. It is not a language model and does not learn; it lets you experience the runtime before choosing a provider. If you already have credentials, jump to the complete tool-calling request.

1. Give the loop a model#

An agent does not know how to reach a specific vendor’s HTTP API. It expects a model adapter: an object that accepts a ModelRequest and streams ModelEvent objects back.

For the first example, the adapter counts user turns and echoes the latest one. It is not an LLM, but it plugs into exactly the same loop a real provider does.

class EchoModel:
    """Deterministic teaching adapter: count user turns and echo the latest one."""

    retry = RetryOptions(max_retries=0)

    async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
        users = [message for message in request.messages if isinstance(message, UserMessage)]
        answer = f"Turn {len(users)}: {users[-1].text}"
        yield ModelEvent.text(answer)
        yield ModelEvent.completed(ModelResponse(AssistantMessage(content=answer)))

Every model step ends with one complete ModelResponse. The text deltas before it exist for display; the final response is what the runtime uses.

2. Create one client, run many turns#

async def main() -> None:
    client = await create_agent(EchoModel())
    first = await client.run("Hello")
    second = await client.run("Remember this conversation")
    assert first.content == "Turn 1: Hello"
    assert second.content == "Turn 2: Remember this conversation"
    print(first.content)
    print(second.content)

create_agent() builds the runtime and returns an AgentClient. Because no session is supplied, it generates one for you.

The second request remembers the first because the default extensions keep in-memory history for the session. run() returns the complete AssistantMessage.

3. Run the whole program#

Download first_agent.py.

A complete offline program#
 1"""A complete offline conversation through the real Agent runtime."""
 2
 3import asyncio
 4from collections.abc import AsyncIterator
 5
 6from zett_agent.client import create_agent
 7from zett_agent.messages import (
 8    AssistantMessage,
 9    UserMessage,
10)
11from zett_agent.model import (
12    ModelEvent,
13    ModelRequest,
14    ModelResponse,
15    RetryOptions,
16)
17
18
19class EchoModel:
20    """Deterministic teaching adapter: count user turns and echo the latest one."""
21
22    retry = RetryOptions(max_retries=0)
23
24    async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
25        users = [message for message in request.messages if isinstance(message, UserMessage)]
26        answer = f"Turn {len(users)}: {users[-1].text}"
27        yield ModelEvent.text(answer)
28        yield ModelEvent.completed(ModelResponse(AssistantMessage(content=answer)))
29
30
31async def main() -> None:
32    client = await create_agent(EchoModel())
33    first = await client.run("Hello")
34    second = await client.run("Remember this conversation")
35    assert first.content == "Turn 1: Hello"
36    assert second.content == "Turn 2: Remember this conversation"
37    print(first.content)
38    print(second.content)
39
40
41if __name__ == "__main__":
42    asyncio.run(main())

After saving the download as first_agent.py in an environment where you installed Zett Agent:

python first_agent.py

Or, from a checkout:

uv run python docs/_examples/first_agent.py

Expected output:

Turn 1: Hello
Turn 2: Remember this conversation

What happened?#

create_agent initialized a conversation with default history. Each run appended a user message, called the adapter, and returned its final assistant message. On the second call, the adapter counted two user messages because the previous turn was restored. You did not need to pass history manually or write a conversation loop.

Real providers can request tools before answering. The runtime executes those tools and calls the model again with their results; a single run can therefore involve several model steps. Try the deterministic tool round trip to see that behavior before making a paid request.

Next: switch the scripted model for a real provider#

Only the adapter construction changes. The same client, messages, extensions, tools, and event callbacks work with every built-in provider. Continue with Connect a model provider, then Stream to a terminal or UI.

Warning

Passing extensions=[] removes the optional defaults, including in-memory history. Each request starts with fresh context; add InMemoryMessageAccumulator or a persistence extension when you want the conversation to carry over.

Related API: Agent, AgentRunConfig, InMemoryMessageAccumulator.