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
pip install zeocoreThat 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
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:
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: intname and version are set as class attributes, not passed in at call time. This is the
idiomatic pattern in the whole codebase.
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}"},
)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:
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),
)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.
What BaseZeoTool, ToolContext, and CapabilityResult actually enforce
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().
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.
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:
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:
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.
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.
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.

