Zipcoil is a little Python library I’ve developed to connect OpenAI and Azure OpenAI agents to your existing Python functions. It’s focused on keeping things simple, ‘cause I’m too old to keep them complicated.

Building an agent usually means converting Python functions into JSON schemas, checking whether the model wants to call a tool, running that tool, sending back the result, and doing the whole thing again. Zipcoil takes care of that plumbing and of nothing else to keep things simple – you supply the client, model, and functions.

You only need to add @tool to your functions to generate their JSON schemas. An Agent handles calling the tools, sending their results back to the model, and doing the whole thing again until it’s finished. You supply the client, model, and functions.

Try it out

You’ll need Python 3.11 or newer.

pip install zipcoil

Set OPENAI_API_KEY in your environment, then define a tool and give it to an agent:

from openai import OpenAI
from zipcoil import Agent, tool


@tool
def add(a: int, b: int) -> int:
    """Add two numbers.

    Args:
        a: First number.
        b: Second number.
    """
    return a + b


agent = Agent(model="gpt-4o", client=OpenAI(), tools=[add])
messages = [{"role": "user", "content": "What is 17 + 25?"}]
result = agent.run(messages=messages)
print(result.choices[0].message.content)

The decorator reads the function’s type hints and Google-style Args docstring to build the tool schema. When the model asks to use add, the agent calls it and sends the result back. It repeats this until the model returns a final response or the iteration limit is reached.

The result is an ordinary OpenAI ChatCompletion, so code that already reads result.choices[0].message.content keeps working. The model belongs to the agent; you don’t need to pass it on every call to run().

The good parts

  • Simple code. A simple @tool decorator converts Python functions into OpenAI tools. The agent handles the conversation flow, including multiple rounds of tool calls and responses.
  • Your function is the tool definition. Parameter types and docstrings supply the schema and descriptions. I’ve spent a lot of time getting this right: nested lists, mixed value types, nullable enums, and dictionaries. Primitives, optionals, unions, and Any are supported too, with some strict-mode restrictions described below.
  • Familiar SDK objects. You pass in an OpenAI client and get back its completion objects or streaming chunks.
  • Tool errors go back to the model. Malformed JSON arguments, missing tools, and exceptions raised by your tools become tool-result messages. That gives the model a chance to correct its request.
  • A tiny dependency list. Zipcoil builds on the official OpenAI library (duh🤷🏻‍♂️) and docstring-parser.
  • Apache 2.0. Cherry on top: a permissive license that makes Zipcoil easier to adopt and distribute in commercial projects.

OpenAI, Azure, sync and async

Zipcoil works with both OpenAI and Azure OpenAI clients:

Agent Client Tools
Agent OpenAI or AzureOpenAI Synchronous functions
AsyncAgent AsyncOpenAI or AsyncAzureOpenAI Synchronous and asynchronous functions

For Azure, configure your Azure client as usual and pass your deployment name as the agent’s model. Zipcoil leaves authentication, endpoints, and API-version configuration to the SDK client.

Here’s the same example using AsyncAgent and an async tool:

import asyncio

from openai import AsyncOpenAI
from zipcoil import AsyncAgent, tool


@tool
async def add(a: int, b: int) -> int:
    """Add two numbers.

    Args:
        a: First number.
        b: Second number.
    """
    return a + b


async def main() -> None:
    async with AsyncOpenAI() as client:
        agent = AsyncAgent(model="gpt-4o", client=client, tools=[add])
        messages = [{"role": "user", "content": "What is 17 + 25?"}]
        result = await agent.run(messages=messages)
        print(result.choices[0].message.content)


asyncio.run(main())

AsyncAgent awaits async tools. Synchronous tools still run inline, so blocking I/O in those tools will block the event loop.

Streaming

Your users can see responses as they arrive, while Zipcoil handles the tool calls in between. Set stream=True:

stream = agent.run(messages=messages, stream=True)
for chunk in stream:
    delta = chunk.choices[0].delta.content
    if delta:
        print(delta, end="", flush=True)

This uses the agent and messages from the first example. The chunks are compatible with chat.completions.create(stream=True). With AsyncAgent, use await agent.run(..., stream=True) and then async for chunk in stream: ....

Strict mode and types

@tool uses strict=True by default. Strict mode constrains the model’s arguments to the generated schema: an int parameter, for example, must receive a JSON integer.

The main tradeoff is that some Python annotations describe more flexible inputs than strict mode allows:

Input annotation Strict mode
str, int, float, bool Supported
Enum, int | None, str | int Supported
list[int], list[str | None] Supported
dict[str, int] or a bare dict Requires strict=False
A bare list or Any Requires strict=False
list[dict[str, int]] or list[Any] Requires strict=False

A dictionary can have keys that aren’t known in advance. OpenAI’s strict mode requires object schemas to declare every permitted key and forbid additional keys. For an input like that, opt out of strict mode on the tool:

@tool(strict=False)
def total(values: dict[str, int]) -> int:
    """Add the supplied values.

    Args:
        values: Named integer values to add together.
    """
    return sum(values.values())

With strict=False, the model still receives the schema, but it may supply the wrong type or omit an argument. The setting applies to all arguments of that tool; other tools keep their own setting.

Zipcoil checks input annotations for strict-mode compatibility when the function is decorated. An incompatible annotation raises ValueError before any API request, naming the tool and parameter and suggesting @tool(strict=False).

These restrictions concern inputs from the model. A tool can return a dictionary or list, or have an Any return annotation, regardless of its strict setting.

I recommend keeping strict mode on and being specific with type hints. Use list[int] when you mean a list of integers. Reach for Any when the input really can be anything.

Some tradeoffs

Type hints aren’t runtime validation. Zipcoil checks whether it can build the schema, but it doesn’t validate the values passed when a tool runs. Validate business rules inside your functions. Enum arguments arrive as their JSON values, so use something like Priority(priority) if your function needs an enum instance.

The API scope is specific. The agent loop uses OpenAI’s Chat Completions API. It doesn’t implement the Responses API or adapters for other providers’ native SDKs. If those are central to your app, choose a library that supports them directly.

Your application owns state and workflow. Conversation storage, resumable workflows, coordinating multiple agents—you’ll need to bring those yourself. Zipcoil handles the tool-calling loop. Keeping that scope small is very much intentional. For more involved workflows, you might be better served by something like MAF.

Error handling has limits. Tool exceptions are sent back to the model, which means exception messages become part of the conversation. Authentication failures and other SDK/API errors can still propagate to your code. A tool-result error also doesn’t undo anything the tool already did.

My recommendations

I generally use Zipcoil for straightforward assistants and internal tools where I already have Python functions and an OpenAI or Azure OpenAI client.

Start with a few focused tools, explicit type hints, and docstrings that explain what the parameters mean. Keep strict mode enabled where possible. Use AsyncAgent when the rest of your application is async.

I also recommend setting the iteration limit explicitly for your use case:

result = agent.run(
    messages=messages,
    max_iterations=5,
    max_completion_tokens=1000,
)

The default is 10 iterations. Here, five means up to five model turns; a turn can request multiple tools. If the model hasn’t finished by then, Zipcoil raises RuntimeError. The token limit is passed to each completion request, so it isn’t a total budget for the whole run.

If you need complete control over every model turn, writing the loop directly with the SDK may be simpler. Simple things should be simple, after all.