Why this one, and what it actually is
You can write a Python function. The next step is making its inputs, outputs and failures clear to the caller. This tutorial wraps a word counter in ZeoCore's tool contract, then exercises the request and result boundaries. You will see where validation runs and what still depends on your implementation.
Bring Python 3.10 or later, basic classes and a terminal. Familiarity with Pydantic helps, but the models below show the fields being checked. This is a small local exercise, not a deployed agent or a production-readiness assessment.
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/profrodai/zeocore, version 0.1.0, MIT licensed. This walkthrough is bound to the v0.1.0 source, rather than a claim about the latest release or its current adoption. Treat it as an early framework example; validation behavior is not a record of production maturity.
The source separates tool identity, call context and result validation. Inspect those boundaries in the example before transferring the design to your own application. Neither type annotations nor a passing framework test suite establishes that a new tool computes the right answer.
Install
pip install zeocore==0.1.0Use an isolated Python environment for this exercise. The command selects the release discussed here; do not assume an unpinned future release has identical behavior. No extras are needed for the word counter. The base dependencies are jinja2, pydantic, pyyaml, rich, and tqdm. Pydantic validates the request, context and result models. Ordinary Python checks separately enforce tool identity and abstract-method requirements. The clean-install path has not been reproduced here; the execution receipt below names the existing environment used to test the example.
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.
This example uses subclassing. Its request and response classes make the fields available to Pydantic and type-checking tools; the annotations do not compel every subclass to validate its input.
Build the tool
The blocks below explain the pieces of one program. To run the complete example, download
minimal_tool.py from the same tag
into an examples folder in your exercise directory. That file includes the temporary-directory
setup, logging imports, initialization check and main() call omitted from the explanatory
fragments. Its temporary directory is cleaned up when the run finishes.
This word counter uses Pydantic models for its request and response. Here is the complete pair:
validation happens when these models are constructed, not automatically on every call to run.
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: intThis example sets name and version as class attributes. BaseZeoTool also accepts identity
when you construct the tool with __init__. Construction configures the instance; invocation
calls run(request, ctx) on that instance. These are different moments, and the class-attribute
style is not the only supported way to construct a tool.
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}"},
)In this example, the caller builds the context and hands it to the tool. The call site below shows that boundary; it is not a claim that a Python subclass cannot construct another context.
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. The tagged file's input is
"ZeoCore is a capability-authoring framework for doctrine-compliant tools."
Splitting that exact string yields 8 words; its length is 73 characters. Compare your output
with those values. The program uses .split() and len(), so it illustrates the contract around
a simple function, not linguistic word segmentation.
What the tagged classes check
Inspect the exact implementation alongside these claims: BaseZeoTool identity and methods, ToolContext fields and mapping boundaries, and CapabilityResult validators. These are v0.1.0 implementation claims, not promises about every subclass or future version.
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. For ordinary attribute assignment, identity is frozen after construction: the base class
overrides __setattr__ so that reassigning .name or .version raises AttributeError.
That is a cooperative Python interface, not a security boundary against code that changes
_identity_frozen or bypasses the setter. 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
the mapping's keys cannot be reassigned through the context. This is a shallow boundary: a list
stored inside metadata, or a referenced service, can still be mutated. Do not treat frozen context
fields as isolation from all shared-state changes. 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().
The receipt below establishes minimal_tool.py in the named existing environment. It does not
establish a fresh package installation or the optional Google example. Inspect each example's
imports and declared dependencies before extending that result to another path.
Break it, the first way: forget the required field
One construction failure to test is a BaseZeoTool subclass that forgets 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, for this request model. A different subclass accepting raw input must establish its own
validation path; BaseZeoTool.run is abstract and does not wrap subclass calls in Pydantic.
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 guide construction toward the fields appropriate for a status. You still need tests of
your tool's behavior; a valid success envelope can carry an incorrectly computed count.
The extras problem: installation dependencies and imports
This second example illustrates a packaging boundary. Its fresh-install behavior has not been reproduced for this revision, so treat the proposed remedy below as a check to perform, not a verified installation guarantee.
Download toolkit_usage.py from v0.1.0
into the same examples folder. The earlier article recorded the following import failure for
python examples/toolkit_usage.py. It is a historical reported traceback, not a fresh execution
receipt from this revision:
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 perform a live upload in that scenario. However, toolkit_usage.py imports
GoogleDriveService unconditionally at module level, above main(), before any decision about
which services are wired gets made.
Python extras declare additional installation dependencies. They do not control when Python executes an import. An unconditional module-level import runs while the module loads; if its dependency is unavailable, loading can fail before a function using that dependency is called.
The drive extra is a proposed way to install the Google integration's dependencies. The command
below is unverified on a clean install in this revision. To test it, use a disposable virtual
environment, record the base-install result, install the extra, and rerun the same example. Only
that comparison can show whether this command resolves the reported failure in your environment.
No Google credentials or live service call are needed for the empty-services scenario described here.
pip install 'zeocore[drive]==0.1.0'
Verification record and limits
On 7 September 2026, the v0.1.0 source at commit
4ce18ba3f051f64c7b6097896b66fc568f7e2e86 produced 8 words and 73 characters in
an existing Python 3.14.3 environment with Pydantic 2.13.4. Missing tool identity,
the wrong request type and the invalid success envelope each raised the expected
exception. A nested metadata list remained mutable, confirming the shallow-freeze limit.
This was a source-run check, not a fresh package install or Windows test. The optional
Google-dependency example was not rerun in a bare environment in that check.
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. The timing matters: identity is checked at tool construction, input at request construction,
and the result when its envelope is built. A result failure may occur after business logic
has run. None of these checks proves semantic correctness, permission to act, or rollback of
an external effect. Carry that distinction into the next function you expose as a tool.
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.

