Skip to content
Practice2026-08-1712 min readRev. 2026-08-17

Build Your First ZeoCore Tool, Then Watch It Fail Twice

Install a real open-source framework, subclass one class, and run it. Then break it on purpose two different ways, because a tool you have not watched fail is a tool you do not understand yet.

Key takeaways

  • BaseZeoTool and ToolContext are not naming conventions. Missing a required field raises TypeError or ValidationError before your code runs.
  • ToolContext is a frozen Pydantic model, so a tool cannot mutate the context another tool depends on, by construction rather than by discipline.
  • CapabilityResult enforces its own status invariants with a model_validator, which is why status=success with a machine_message set fails to construct at all.
  • ZeoCore's own README claims every example runs as-is, and one example contradicts that on a bare install, which is a real lesson about how Python extras gate imports.

Rod Rivera

Author

Build Your First ZeoCore Tool, Then Watch It Fail Twice

Rod's note — read with a pencil; the margins are for you.

Why this one, and what it actually is

This is a build-it-yourself tutorial, not a tour of a README. You will install a small Python package, write one real tool (a working word counter, about 15 lines), run it successfully, then break it two different ways on purpose, because the failure modes teach you more about what a typed framework enforces than the happy path does.

The framework is ZeoCore: a small Python library for writing agent tools (the functions an LLM calls to actually do something, like read a file or hit an API) with typed, runtime-enforced contracts instead of loose dictionaries and hopeful docstrings. I wrote it. It is at github.com/zeroemployeeorg/zeocore, version 0.1.0, MIT licensed, and it has zero stars because it was tagged for the first time this month. The README's own "Project status" section says this outright: early, first release, no track record of production use yet. I am not going to pretend otherwise in this piece either.

What it does have at v0.1.0: mypy --strict passing across the codebase and over 2,000 tests. That is not a claim about maturity. It is a claim about what the type system and the test suite will catch before a tool you write ships a bug to a model that trusts its output blindly.

Install

bash
pip install zeocore

That is the whole install. No extras, for this tutorial. The base dependencies are jinja2, pydantic, pyyaml, rich, and tqdm, pulled in automatically. Pydantic is the one that matters most here. It is not a helper library ZeoCore happens to use. It is the mechanism that makes the rest of this piece true.

The three pieces you are about to use

Three names carry the entire tool contract, and all three come from real files in the repo, not from documentation prose:

  • BaseZeoTool (src/zeo_core/tools/base.py): the abstract base class every tool subclasses.
  • ToolContext (src/zeo_core/tools/context.py): the object a tool receives at call time, carrying its run id, its logger, its filesystem service, and its working directories.
  • CapabilityResult (src/zeo_core/contracts/envelopes/result.py): the typed envelope every tool must return.

There is no decorator path in this codebase. Pure subclassing is the only way to define a tool, which is a deliberate choice: a class body is a place mypy can check.

Build the tool

Define the request and response models

Every tool's input and output are typed Pydantic models, not dictionaries. Here is the complete request and response pair for a word-count tool:

python
from __future__ import annotations
import logging
from pydantic import BaseModel
from zeo_core.contracts import CapabilityResult
from zeo_core.core.fs import get_service as get_fs_service
from zeo_core.tools import BaseZeoTool, ToolContext


class WordCountRequest(BaseModel):
    text: str


class WordCountResponse(BaseModel):
    word_count: int
    char_count: int
Subclass BaseZeoTool

name and version are set as class attributes, not passed in at call time. This is the idiomatic pattern in the whole codebase.

python
class WordCountTool(BaseZeoTool):
    name = "word_count"
    version = "1.0.0"

    def run(
        self, request: WordCountRequest, ctx: ToolContext
    ) -> CapabilityResult[WordCountResponse]:
        logger = ctx.require_logger()
        if logger is not None:
            logger.info(f"[{self.name}] counting words in {len(request.text)} chars")
        words = request.text.split()
        return CapabilityResult.ok(
            data=WordCountResponse(
                word_count=len(words),
                char_count=len(request.text),
            ),
            msg="Word count completed",
            metadata={"tool": f"{self.name} v{self.version}"},
        )
Build a ToolContext

A tool never constructs its own context. The caller builds one and hands it in. There is no separate runner package in this repo, so the call site below is the whole invocation path:

python
ctx = ToolContext(
    run_id="minimal-run-001",
    tool_name="word_count",
    tool_version="1.0.0",
    logger=logging.getLogger("word_count"),
    fs=get_fs_service(),
    work_dir=str(tmp_dir),
    output_dir=str(tmp_dir),
)
Invoke it
python
tool = WordCountTool()
init_result = tool.initialize(ctx)
request = WordCountRequest(text="...")
result = tool.run(request, ctx)
if result.status != "success":
    raise RuntimeError(result.human_message)
print(result.data.word_count)

Run python examples/minimal_tool.py against the file's own sample sentence and you get exactly what the class computed: "Hello, World!" comes back as 8 words, 73 characters. Nothing generated, nothing approximated. It is .split() and len() wrapped in a contract, and that is the point. The value is in the contract, not in the arithmetic.

How one call moves through the contract

What BaseZeoTool, ToolContext, and CapabilityResult actually enforce

Quick check — If you subclass BaseZeoTool but forget to set the class attribute name, what happens?

The natural reading of "BaseZeoTool," "ToolContext," and "CapabilityResult" is that they are naming conventions: three classes that exist to make code readable, the way a lot of frameworks use base classes as loose organizational scaffolding you could technically ignore. That reading is wrong here, and the source files are specific about why.

BaseZeoTool is an abc.ABC with one abstract method, run(self, request, ctx) -> CapabilityResult. You cannot instantiate a subclass that has not implemented it. name must be set, either as a class attribute or passed to __init__, or __init__ raises TypeError. version defaults to "1.0.0" if you do not set it. And identity is frozen after construction: the base class overrides __setattr__ so that reassigning .name or .version on a live tool instance raises AttributeError. Two hooks exist with no-op defaults if you do not need them: initialize(self, ctx) and is_available(self, ctx) -> bool.

ToolContext is a frozen Pydantic BaseModel (ConfigDict(frozen=True)), not a dataclass and not a dict. Frozen means what it says: you cannot set an attribute on a context after it is built. Its required fields are run_id, tool_name, tool_version, logger, fs, work_dir, and output_dir. Two optional fields, services and metadata, are wrapped in MappingProxyType so even a mutable mapping you pass in becomes read-only once it is inside the context. The convenience methods matter in practice: .require_service(name) raises a ValueError that lists every available service by name if the one you asked for is not there, which is a debugging detail a plain dict lookup would never give you.

CapabilityResult is a generic Pydantic model with extra="forbid", so a typo'd field name fails construction instead of silently vanishing. Its status field is one of success, skipped, or error, and a model_validator(mode="after") enforces real cross-field rules: status=error requires both error and machine_message to be set; status=success must have neither; status=skipped requires machine_message but forbids error. machine_message itself has a field_validator requiring it to start with "QC_". You are not meant to construct a CapabilityResult directly in normal use. Four constructors do it for you: .ok(), .skip(), .fail(), and .fail_from_exc().

A README claim that is true for one example and false for another

ZeoCore's README states plainly: "Every example is runnable as-is: python examples/<name>.py. None of them are illustrative fragments." That claim holds for minimal_tool.py, which is the tool built above. It does not hold for toolkit_usage.py on a bare pip install zeocore. The next section has the exact traceback and shows why the fix is one word in the install command, not a code change.

Break it, the first way: forget the required field

The single most likely first mistake, because it is the one every new user of a typed framework makes at least once: subclass BaseZeoTool and forget to set name.

python
class BrokenTool(BaseZeoTool):
    def run(self, request, ctx):
        ...

BrokenTool()

That raises immediately, before run is ever reachable:

TypeError: BrokenTool must either: 1. Define 'name' as a class attribute, or 2. Pass 'name' to __init__() Example: class BrokenTool(BaseZeoTool): name = 'my_tool'

The error message tells you the fix inline, which is a small design choice worth noticing: the base class does not just refuse to construct, it hands you the corrected class body in the exception text. Fix it by adding name = "broken_tool" as a class attribute, and the tool constructs.

The second failure mode is the request model doing its job. Pass the wrong type into a field that WordCountRequest declared as str:

python
request = WordCountRequest(text=12345)
pydantic_core._pydantic_core.ValidationError: 1 validation error for WordCountRequest text Input should be a valid string [type=string_type, input_value=12345, input_type=int] For further information visit https://errors.pydantic.dev/2.13/v/string_type

That error happens at request construction, before WordCountTool.run sees the request at all. The type contract on the model is doing the checking a hand-written if not isinstance(...) block would otherwise need, and it does it the same way for every tool in the codebase rather than depending on each tool author remembering to write that check.

Break it, the second way: violate the result contract

The third real error is more interesting because it is not a mistake a beginner makes by accident, it is what happens when someone bypasses the constructors and builds a CapabilityResult by hand:

python
CapabilityResult(
    status="success",
    data=WordCountResponse(word_count=1, char_count=5),
    human_message="Word count completed",
    machine_message="QC_UNEXPECTED",
)
pydantic_core._pydantic_core.ValidationError: 1 validation error for CapabilityResult Value error, status=success should not have machine_message (success is the default path, no special routing needed) [type=value_error, ...]

Read the message closely: it is not a generic "invalid field" error, it explains the reasoning. Success is the default path and does not need a routing code, so setting one on a successful result is treated as a contract violation, not just an unusual choice. This is the model_validator from the source file, and it is why .ok(), .skip(), and .fail() exist as constructors in the first place. They are the only way to build a result that is guaranteed to satisfy its own invariants, because they never let you set the fields that only make sense for a different status.

The extras problem: when "runs as-is" is not quite true

Here is the part of this piece that is less flattering to the framework, and worth a full section rather than a footnote, because it is a real and useful lesson about Python packaging, not just a ZeoCore bug report.

Run python examples/toolkit_usage.py on a bare pip install zeocore and it fails before it does anything at all:

Traceback (most recent call last): File ".../examples/toolkit_usage.py", line 45, in <module> from zeo_core.integrations.google.drive import GoogleDriveService File ".../zeo_core/integrations/google/__init__.py", line 9, in <module> from zeo_core.integrations.google.auth import GoogleAuthProvider File ".../zeo_core/integrations/google/auth.py", line 11, in <module> from google.auth.transport.requests import Request ModuleNotFoundError: No module named 'google'

The demo scenario inside toolkit_usage.py never actually wires up a live Google Drive connection. It deliberately runs with services={} to demonstrate graceful degradation, and the expected output on a working run is Uploaded file id: None. So the example does not need Google's auth library to do what it demonstrates. It fails anyway, because toolkit_usage.py imports GoogleDriveService unconditionally at module level, above main(), before any decision about which services are wired gets made.

The fix is not a code change. It is the install command below, with the extra named.

The lesson generalizes past this one example: Python's optional-dependency extras system gates imports, not calls. A module-level import statement runs the moment the file loads, regardless of whether the function that uses that import ever executes. An integration you never call can still crash your program on startup if the file that defines it imports its dependency at the top instead of inside the function that needs it. That is true in any Python codebase with optional integrations, and toolkit_usage.py is a real, reproducible example of it happening, not a hypothetical.

So the README's "every example is runnable as-is" is accurate for the tool you just built and inaccurate for this one specific example, on a bare install. Both statements are true at once, and knowing which one applies before you hit the traceback is the entire value of this section.

Terminal
$
pip install 'zeocore[drive]'

What to take from this

The three classes at the center of ZeoCore are not there to make the code look organized. They are runtime checks: a missing name stops a tool from existing, a wrong type stops a request from existing, and a malformed result stops a CapabilityResult from existing. Each of those failures happens before your business logic runs, which is a different guarantee than "the framework will probably catch this eventually." At v0.1.0, zero stars, first release, that guarantee is the honest value proposition. Not maturity. Not a community. Contracts that fail loudly and specifically when you violate them, and 2,000-plus tests making sure they keep doing that.

If you want to see the same design question worked from a different angle, the five tool-design principles lesson below covers what makes a tool safe to hand to a model in the first place, and where a class-based contract like this one fits against CLI wrappers, MCP, and the other ways to define a callable tool.

Read the five tool-design principles

The lesson this tutorial's contract design maps onto: what a tool needs structurally to be safe for an agent to call, worked across CLI wrappers, functions, APIs, MCP, and A2A.

Ready to put an agent to work?

Join the Prof Rod newsletter for one educational lesson a week, with worked examples attached. It is free to register for and separate from the Zero Employee community.