Moving the team out of Python#
In the previous article, I
brought the Akgents modules together in one case-handling workflow. Five agents form a team. Two use
an LLM, two keep the workflow deterministic, and one connects the team to a person. The structure
is described by a TeamCard, and TeamManager turns that description into running actors.
That version works. In fact, building a fixed team in Python is still the right amount of machinery for many applications. But the team declaration also exposes the next problem. The model, prompts, tools, agent cards, usage limits, and member tree are all baked into source code:
CASE_MODEL = ModelConfig(
provider="openai",
model="gpt-4o-mini",
temperature=0.0,
)
triage_agent_card = AgentCard(
description="Judge how urgent a case is and propose a priority.",
skills=["triage"],
agent_class=CaseTriageAgent,
config=CaseTriageConfig(
name="@CaseTriage",
role="Triage",
prompt=PromptTemplate(template=TRIAGE_PROMPT),
model_cfg=CASE_MODEL,
tools=[CaseTool()],
),
)Changing a prompt means changing Python. Sharing a model means sharing a Python object. Reviewing a team means reading constructors and following constants through several modules. A typo may only become visible after an agent has started.
This is the problem
akgentic-catalog solves. It lets the
same declaration live as data instead of code: validated, referenceable, persistent, queryable, and
portable between storage backends.
The word same matters. The catalog does not replace TeamCard, AgentCard, ModelConfig,
PromptTemplate, or ToolCard. It stores instances of those models and gives me those same types
back when I load a team. Before the refactor, case_team_card() returned a TeamCard. After it,
catalog.load_team(namespace) returns a TeamCard. Everything downstream can remain unchanged.
That made the catalog click for me. It is not another agent runtime. It is the validated configuration boundary in front of the runtime.
Why a catalog earns its place#
Moving constructors into data is not automatically an improvement. If I only ever have one fixed team and every change travels through a code release, the Python version is direct, typed, and easy to understand. I would keep it.
The catalog earns its place when one or more of these pressures appear:
- I want to change a model or prompt without deploying application code. The source can be a YAML file, an administration interface, or an API instead of a Python constant.
- I want several agents or teams to share one configuration object. An edit to the shared model or tool should reach all of its consumers instead of updating copied values one by one.
- I want a malformed configuration to fail at the boundary. A misspelled field should not become a strangely behaving agent three calls later.
- I want to export, diff, validate, clone, and version a complete team as data.
- I want local YAML today and a database later without changing the code that resolves a team.
The first version of my case team already contained the evidence. CASE_MODEL was passed to both
LLM agents. TRIAGE_PROMPT and EXECUTOR_PROMPT lived elsewhere but were wrapped inline. Two
identical CaseTool() instances were constructed separately. Five AgentCards were nested by hand
inside one TeamCard.
Python references solved reuse inside one process. They did not give me persistent identity, cross-team sharing, external editing, delete protection, or a whole-team document that I could validate before importing.
flowchart TB
PY["Python constants and constructors"] --> PYCARD["TeamCard"]
PY -. "refactor" .-> ENTRIES["model + prompts + tool + agent entries"]
ENTRIES -->|"__ref__"| TEAM["team entry in one namespace"]
PYCARD -. "same output type" .-> LOAD["catalog.load_team(...)"]
TEAM --> LOAD
LOAD --> RUNTIME["TeamManager remains unchanged"]
What the catalog is—and what it is not#
The catalog has a deliberately narrow responsibility. It stores configuration, validates it, resolves references, and reconstructs typed objects. It does not start actors, route messages, run models, call tools, or manage the lifecycle of a team.
That keeps the package boundary clear:
| Concern | Owner |
|---|---|
| Describe models, prompts, tools, agents, and teams | akgentic-catalog |
| Turn a description into actors | akgentic-team |
| Run actor mailboxes and typed messages | akgentic-core |
| Run the ReAct loop | akgentic-llm |
| Build runtime tool surfaces | akgentic-tool |
| Combine actor, LLM, and tool behaviour | akgentic-agent |
The catalog is also not a generic document store. Every payload is validated against a named Pydantic model. Unknown keys are errors. If I need schema-free JSON storage, the rigidity of the catalog works against me. Here, that rigidity is the feature.
Install the configuration layer#
The sample uses Python 3.12 or 3.13 and locks akgentic-catalog 2.2.6. I added the package directly:
uv add akgentic-catalogThe base installation contains the Catalog service and YAML repository. Optional extras add the
CLI, REST API, MongoDB, or PostgreSQL surfaces. The application code works through the same catalog
service either way, so those are deployment choices rather than new team designs.
The code for this episode lives in my
basic-akgents sample. The new module is
src/basic_akgents/catalog/case_team_catalog.py, while the original Python declaration remains in
case_team_card.py as a useful before-and-after comparison.
One shape for everything: Entry#
Every item in the catalog uses one envelope, Entry:
from akgentic.catalog import Entry
Entry(
id="case-triage",
kind="agent",
namespace="case-handling-team",
user_id="u1",
model_type="akgentic.core.AgentCard",
description="Judge how urgent a case is and propose a priority.",
payload={...},
)The shape becomes easier to reason about when I keep its three layers separate:
| Layer | Fields | Purpose |
|---|---|---|
| Catalog envelope | id, kind, namespace, user_id, description | Identity, ownership, grouping, and queries |
| Model selector | model_type | Dotted path of the Pydantic class that defines the payload |
| Model data | payload | Plain data validated through model_type |
kind is one of team, agent, tool, prompt, model, or meta. It makes it possible to
query the catalog without understanding each payload. model_type stays open: it can name a
framework model or an explicitly allowed application model.
That distinction gives the catalog one generic service without throwing away domain types. The
repository stores one entry shape; the resolver still returns a real AgentCard, ModelConfig, or
TeamCard.
Unknown keys fail early#
The most valuable validation rule is also the simplest: a key that the target Pydantic model does not declare is always an error.
If I write skils instead of skills in an AgentCard payload, the catalog refuses the write and
names the offending field. It does not quietly create an agent with an empty skill list. This is
stricter than constructing many Pydantic models directly, where an extra key may be ignored unless
the model has opted into forbidding extras.
The same rule applies to bundles and nested payloads. A team definition should never look valid only because the reader silently discarded the part it did not understand.
There is one honest limitation: leaving out a field is not the same as misspelling it. If a field has
a valid default, an omitted field can still produce valid configuration. I ran into this with
AgentConfig.tools. Leaving the key out gives the agent an empty tool list. The catalog cannot know
whether I intended that or forgot the capability.
The write is valid; the behaviour is wrong. The triage agent then has no way to load a case and may spend its tool budget trying the framework’s intrinsic team and mailbox tools. When porting an existing Python card, I therefore treat every original field as a checklist item. Boundary validation catches malformed data, not missing intent.
model_type is an import boundary#
Resolving model_type imports the class named by the dotted path. Catalog data must not become an
unrestricted list of Python modules to import, so model types are guarded by a prefix allowlist.
Everything below akgentic. is allowed by default. My custom CaseTool is not:
Entry(
id="case-tool",
kind="tool",
namespace=NAMESPACE,
user_id=OWNER,
model_type="basic_akgents.case_tools.CaseTool",
payload={},
)I explicitly allow the narrowest prefix that covers that class:
from akgentic.catalog import set_allowed_prefixes
set_allowed_prefixes(["basic_akgents.case_tools."])I do this once, during module import, before constructing an entry. Every process that writes or
resolves those entries must apply the same allowlist. A broad basic_akgents. prefix would work, but
it would authorize many classes the catalog never needs. Treating this as a security boundary leads
to a better default than treating it as naming ceremony.
A namespace represents one team#
The central organizing idea is not the directory layout. It is this: one namespace represents one agent team.
The case-handling-team namespace contains the team entry, five agent entries, a shared tool, a
shared model, and two prompt bodies. Together they are the configuration of one team.
An id is unique inside the namespace, regardless of kind. I cannot create both an agent and a model
named case-triage in the same namespace. kind partitions queries; it does not add another level
to the identity.
A namespace also needs an anchor before other entries can be added. The anchor is either:
- a
teamentry, which is the domain description with an entry point, members, message types, and otherTeamCardfields; - a
_metaentry, which describes the namespace itself and can exist without a team.
The _meta entry has two flags that matter when the catalog grows beyond a local sample:
shareableallows other namespaces to reference entries in this namespace;publicallows non-owners to see and clone the namespace.
Those are different permissions. Sharing controls incoming references. Public visibility controls discovery and cloning. A global namespace containing reusable model configurations may be shareable without containing a team at all.
My case sample needs neither flag. Everything belongs to one local team namespace. Keeping that boundary closed makes deletion and rollback simpler too: no other namespace can point into it.
References turn copies into shared configuration#
In the Python version, CASE_MODEL is passed to triage and executor as the same object. Once the
configuration lives as data, identity needs a data representation. The catalog uses a reference
marker:
model_cfg:
__ref__: shared-case-model
__type__: akgentic.llm.ModelConfigA marker supports three keys:
__ref__is required and names the target entry id;__type__is optional and pins the expected target model type;__namespace__is optional and points into another shareable namespace.
The marker is a pure pointer and a leaf. I cannot place an override beside it:
# Invalid: a reference marker cannot also patch the target.
model_cfg:
__ref__: shared-case-model
temperature: 0.7That rule is what keeps resolution, validation, and delete protection consistent. Every walker can
stop at __ref__ and know that the entire dictionary is one pointer.
Resolution has two phases. First, the catalog walks the payload and replaces each marker with the
resolved target, following nested references recursively. Then Pydantic validates the populated
tree against the entry’s own model_type.
flowchart TB
STORED["Stored agent payload
contains __ref__ markers"]
WALK["populate_refs
walk recursively"]
TARGETS["Resolved ModelConfig,
CaseTool, and prompt value"]
VALIDATE["model_validate
AgentCard"]
AGENT["Typed AgentCard"]
STORED --> WALK
WALK --> TARGETS
TARGETS --> VALIDATE
VALIDATE --> AGENT
The important consequence is that stored data stays normalized. Calling catalog.get(...) still
returns the marker. It does not flatten the target into a private copy. Editing
shared-case-model changes both agents on their next resolve.
Delete protection follows from the same graph. The catalog refuses to delete a model or tool while
other entries reference it, and it can report the referrers through find_references(...). I can
inspect the dependency before I attempt a destructive edit.
References are namespace-local by default. Cross-namespace references only work when the target namespace explicitly opts into sharing. That makes reuse possible without turning every catalog entry into global mutable configuration.
One wrinkle is worth remembering: a reference cycle can be accepted by the write that closes it and then rejected when the graph is resolved. Write-time validation sees the state available at that moment; recursive resolution is where the completed loop becomes visible. My team graph is a tree, so this does not affect the sample, but the timing matters when diagnosing a cycle.
Share bare values with NativeValue#
A whole model can be the target of a normal reference. A bare scalar cannot be an entry’s Pydantic
model on its own. The two prompt bodies in my sample are strings, and PromptTemplate.template is a
string field.
NativeValue is the small wrapper for this case:
catalog.create(
Entry(
id="triage-prompt-body",
kind="prompt",
namespace=NAMESPACE,
user_id=OWNER,
model_type="akgentic.catalog.NativeValue",
description="System prompt for @CaseTriage",
payload={"value": TRIAGE_PROMPT},
)
)The agent can then reference the value from an inline PromptTemplate:
"prompt": {
"template": {"__ref__": "triage-prompt-body"},
"params": {},
}When a reference targets NativeValue, the resolver unwraps its value. The receiving
PromptTemplate.template therefore gets a string, not a wrapper object.
This also shows how to combine shared and local data without breaking the pure-pointer rule. The
PromptTemplate stays inline. Only its shared template field becomes a reference. Per-agent
params remain beside template inside the parent model, not beside __ref__ inside the marker.
I avoid using the dictionary variant of NativeValue to smuggle a typed structure through the
catalog. If the data has a schema, I give it a real Pydantic model. NativeValue is for actual
native scalar or opaque JSON values, not an escape hatch from validation.
Export a complete namespace as a bundle#
Entries are useful individually. A bundle turns the whole namespace into one document:
document = catalog.export_namespace_yaml(NAMESPACE)
fresh_catalog.import_namespace_yaml(document)This is the configuration artifact I can review and version. A diff of the bundle can show that a model changed, a prompt was updated, a tool reference moved, or an agent left the member tree.
Import has intentionally strong semantics:
- it atomically replaces the namespace; it does not merge;
- entries absent from the bundle are removed;
- every validation runs before any write;
- a failed import leaves the existing namespace untouched;
- unknown keys in the bundle and its entries are refused.
That makes a bundle a statement of what a namespace is, not a partial patch. I can dry-run it before importing:
report = catalog.validate_namespace_yaml(document)
if not report.ok:
print(report.global_errors)
print(report.entry_issues)The same distinction is useful in an operations workflow. Validation is a safe read. Import is the state change. A team definition can pass through review without relying on an exception halfway through the real update.
Storage is a deployment choice#
For basic-akgents, YAML is the right backend. It needs no service and makes the stored entries easy
to inspect:
catalog = Catalog(
YamlEntryRepository(Path("./data/catalog"))
)The repository writes one file per entry below the namespace. The important part is not that file
layout; it is the boundary above it. Catalog works through the EntryRepository protocol, while
reference resolution, ownership, namespace rules, validation, and delete protection stay in the
service layer.
That keeps the consuming code stable:
catalog = Catalog(YamlEntryRepository(path))
catalog = Catalog(MongoEntryRepository(collection))
catalog = Catalog(PostgresEntryRepository(dsn))The last two need their optional dependencies and infrastructure, but they do not require another way of defining agents. Growing beyond a YAML directory changes construction, not the team’s data model.
flowchart TB
PY["Python API"] --> CATALOG["Catalog service"]
CLI["ak-catalog CLI"] --> CATALOG
REST["REST API"] --> CATALOG
CATALOG --> RULES["validation, refs, ownership,
namespaces, delete protection"]
RULES --> REPO["EntryRepository protocol"]
REPO --> YAML["YAML files"]
REPO --> MONGO["MongoDB"]
REPO --> POSTGRES["PostgreSQL"]
What user_id actually does#
I was surprised when I noticed user_id in an entry. What is user_id for if every entry in a namespace has to use
the same one?
It is the ownership label, and the catalog enforces its consistency:
- the team or
_metaanchor establishes the owner of a namespace; - every entry below that anchor must carry the same
user_id; - a mismatching entry is refused;
- cloning a namespace stamps the copies with the new owner’s id;
Catalog.as_caller(...)can use it for multi-tenant visibility.
The last point is opt-in. A plain Catalog(repository) does not filter normal reads by caller. In my
single-user local sample, OWNER = "u1" is a required, non-empty, consistent label. It becomes an
access-control input only when the application starts using caller-scoped operations.
Repeating it on every Entry therefore is not decorative metadata. It is a checked invariant that
keeps entries from different owners from accidentally mixing in one team namespace.
Refactoring the case team#
With those concepts in place, the Python-to-catalog rewrite is mostly mechanical. I mapped every
piece of the old case_team_card() to an entry:
| Python declaration | Catalog representation |
|---|---|
Returned TeamCard | One team entry and the namespace anchor |
Five AgentCard instances | Five agent entries |
Shared CASE_MODEL | One model entry, referenced by both LLM agents |
| Two prompt strings | Two NativeValue prompt entries |
Two identical CaseTool() values | One shared tool entry |
Nested TeamCardMembers | References from the team entry to agent entries |
The team still has the same member tree:
case-handling-team
├── @UserProxy entry point
└── @CaseCoordinator
├── @CaseTriage shared model, prompt ref, shared case tool
├── @CaseRepository deterministic repository actor
└── @CaseExecutor shared model, prompt ref, shared case toolWhat the first run writes#
The first run makes the refactor tangible. YamlEntryRepository persists ten files below the fixed
namespace:
data/catalog/case-handling-team/
├── agent/
│ ├── case-coordinator.yaml
│ ├── case-executor.yaml
│ ├── case-repository.yaml
│ ├── case-triage.yaml
│ └── user-proxy.yaml
├── model/
│ └── shared-case-model.yaml
├── prompt/
│ ├── executor-prompt-body.yaml
│ └── triage-prompt-body.yaml
├── team/
│ └── case-handling-team.yaml
└── tool/
└── case-tool.yamlThese are the repository’s individual entry files, not the single-document bundle returned by
export_namespace_yaml(). Each directory corresponds to an entry kind; the parent directory is
the team namespace.
The persisted team entry contains the same hierarchy as the earlier TeamCard, but its cards are
now references:
# team/case-handling-team.yaml (excerpt)
id: case-handling-team
kind: team
namespace: case-handling-team
user_id: u1
model_type: akgentic.team.models.TeamCard
payload:
entry_point:
card:
__ref__: user-proxy
headcount: 1
members: []
members:
- card:
__ref__: case-coordinator
members:
- card:
__ref__: case-triage
- card:
__ref__: case-repository
- card:
__ref__: case-executorOpening the triage entry shows that the normalization continues inside the agent. Its prompt, model, and tool were not copied into this file:
# agent/case-triage.yaml (excerpt)
id: case-triage
kind: agent
namespace: case-handling-team
model_type: akgentic.core.AgentCard
payload:
agent_class: basic_akgents.case_triage.CaseTriageAgent
config:
name: '@CaseTriage'
role: Triage
prompt:
template:
__ref__: triage-prompt-body
params: {}
model_cfg:
__ref__: shared-case-model
__type__: akgentic.llm.ModelConfig
tools:
- __ref__: case-tool
__type__: basic_akgents.case_tools.CaseToolThe model reference points to a small, independently editable entry:
# model/shared-case-model.yaml
id: shared-case-model
kind: model
namespace: case-handling-team
user_id: u1
model_type: akgentic.llm.ModelConfig
payload:
provider: openai
model: gpt-4o-mini
temperature: 0.0The prompt files follow the same pattern with model_type: akgentic.catalog.NativeValue. I leave the
full prompt text out here because the useful lesson is the stored reference, not another copy of a
long system prompt. On resolution, the catalog follows these markers and reconstructs the typed
object graph the Python version built directly.
Use a durable catalog path#
I first followed the package examples with a temporary directory. That is excellent for learning the API and terrible for the real sample. A new process creates a new directory, so every run loses the catalog it just wrote. Editing a generated YAML file has no effect on the next run.
The application already keeps its event store below data/. I used the same project-relative
convention for the catalog:
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[3]
CATALOG_ROOT = PROJECT_ROOT / "data" / "catalog"
catalog = Catalog(YamlEntryRepository(CATALOG_ROOT))Resolving the path from the module rather than the current working directory means make run,
uv run src/main.py, and a test all find the same catalog. data/ was already ignored by Git and
removed by make clean-data, so the new storage follows the sample’s existing lifecycle.
The catalog directory is created lazily on the first write. I do not need installation code just to make an empty folder.
Seed once, then only read#
The old case_team_card() was a pure constructor and safe to call for every case. Catalog writes
are different. create is deliberately not idempotent, and UNSET_NAMESPACE deliberately creates a
fresh namespace.
Combining those two example patterns inside the hot path would be a subtle bug. Every case would mint another namespace and write another copy of all entries. The shared model would only be shared inside one copy, and the YAML directory would grow forever.
I use a fixed namespace and guard the seed:
NAMESPACE = "case-handling-team"
TEAM_ID = "case-handling-team"
def case_team_card_from_catalog() -> TeamCard:
if not _is_seeded(NAMESPACE):
try:
_seed_case_team(NAMESPACE)
except Exception:
_rollback_partial_seed(NAMESPACE)
raise
return catalog.load_team(NAMESPACE)
def _is_seeded(namespace: str) -> bool:
try:
catalog.get(namespace, TEAM_ID)
except EntryNotFoundError:
return False
return TrueThe first case seeds the namespace. Every later case only resolves the existing team. A manual edit to the YAML persists across application restarts and is picked up by the next load.
Roll back a partial seed#
_seed_case_team() creates entries one by one. It is not an atomic bundle import. If the sixth
write fails, the first five have already landed.
Checking only for the team anchor would then lie on the next run: the team entry exists, so
_is_seeded() says the namespace is ready even though several agents are missing. I hit exactly
this class of problem while working through model types and the allowlist.
The failure path deletes the incomplete namespace and re-raises the original error:
import contextlib
def _rollback_partial_seed(namespace: str) -> None:
with contextlib.suppress(EntryNotFoundError):
catalog.delete_namespace(namespace)This is safe for the sample because the namespace is not shareable. Nothing outside it can point to one of the entries being removed, and internal references do not block whole-namespace deletion. The original exception remains visible, but the next retry starts from a clean state.
For an externally managed configuration, I would prefer an atomic bundle import. For a small sample that seeds a known team from Python once, explicit rollback keeps the implementation easy to follow without leaving corrupt state behind.
Anchor the namespace with the team#
The team entry must be written before the sub-entries because it anchors the namespace. On that
first write, there is no user-proxy entry to reference yet. I start with the entry-point card
inline:
placeholder_proxy_card = {
"description": "Bridge between the team and the human at the console.",
"skills": ["ask_human"],
"agent_class": "basic_akgents.cli_user_proxy.CliUserProxyAgent",
"config": {
"__model__": "basic_akgents.cli_user_proxy.CliUserProxyConfig",
"name": "@UserProxy",
"role": "UserProxy",
},
}
catalog.create(
Entry(
id=TEAM_ID,
kind="team",
namespace=NAMESPACE,
user_id=OWNER,
model_type="akgentic.team.models.TeamCard",
payload={
"name": "case-handling-team",
"description": "Handles cases when requested using an id.",
"entry_point": {
"card": placeholder_proxy_card,
"headcount": 1,
"members": [],
},
"members": [],
"message_types": [
{"__type__": "basic_akgents.case_coordinator.HandleCaseRequest"}
],
"metadata_type": {
"__type__": "basic_akgents.case_model.CaseMetaData"
},
"welcome_message": "Case team ready to handle your case.",
},
)
)Once the namespace exists, I create the separate user-proxy agent entry and replace the inline
placeholder with a reference. The two-step bootstrap is not duplication in the final catalog; the
update removes the inline card from the team payload.
Strings and types serialize differently#
The block above contains three related-looking dotted paths with different serialized shapes. This was one of the most useful parts of the refactor.
AgentCard.agent_class accepts a string or a type, so the dotted path can be a plain string:
"agent_class": "basic_akgents.cli_user_proxy.CliUserProxyAgent"TeamCard.message_types and metadata_type are real type fields. A plain string fails Pydantic’s
type validation. Akgents’ serializer represents those values as tagged dictionaries:
"message_types": [
{"__type__": "basic_akgents.case_coordinator.HandleCaseRequest"}
],
"metadata_type": {
"__type__": "basic_akgents.case_model.CaseMetaData"
},This __type__ is not a catalog reference. A dictionary becomes a reference marker only when it
contains __ref__. Here, the framework deserializer turns the tagged path back into the real class
before Pydantic validates the field.
The __model__ tag solves another polymorphic problem. AgentCard.config is declared as the common
BaseConfig, but different agents need different subclasses. The framework can often infer the
config type from the concrete Akgent[Config, State] binding of agent_class.
CliUserProxyAgent inherits through HumanProxy and introduces CliUserProxyConfig through a
class-body annotation. The inference sees BaseConfig, so an untagged dictionary can be reduced to
that base model. The agent then fails later when it expects human_io.
Tagging the dictionary makes the concrete type explicit:
"config": {
"__model__": "basic_akgents.cli_user_proxy.CliUserProxyConfig",
"name": "@UserProxy",
"role": "UserProxy",
}My practical rule is to add __model__ when a polymorphic nested value cannot be inferred from its
generic base class. If I am unsure, an explicit correct tag is safer; it is harmless when inference
would have found the same class.
Store the shared model once#
CASE_MODEL is a complete Pydantic model, so it gets its own entry and needs no wrapper:
catalog.create(
Entry(
id="shared-case-model",
kind="model",
namespace=NAMESPACE,
user_id=OWNER,
model_type="akgentic.llm.ModelConfig",
payload={
"provider": "openai",
"model": "gpt-4o-mini",
"temperature": 0.0,
},
)
)
model_ref = {
"__ref__": "shared-case-model",
"__type__": "akgentic.llm.ModelConfig",
}Both LLM agent entries reuse model_ref. Changing the stored temperature or model reaches triage
and executor together the next time the team resolves.
Make the case tool shared too#
My first attempt kept CaseTool() inline on each agent. It is a serializable ToolCard, though,
and both agents use the same read-only configuration. That is exactly the same reuse problem as the
model.
I promote it to an entry:
catalog.create(
Entry(
id="case-tool",
kind="tool",
namespace=NAMESPACE,
user_id=OWNER,
model_type="basic_akgents.case_tools.CaseTool",
description="Read-only case lookup shared by triage and executor",
payload={},
)
)
case_tool_ref = {
"__ref__": "case-tool",
"__type__": "basic_akgents.case_tools.CaseTool",
}The empty payload is meaningful. CaseTool defaults to the sample repository backend and
allow_writes=False. Triage and executor may read cases inside their ReAct loops, but neither model
gets a write tool. The deterministic handlers still perform writes after human approval.
If I change the backend or deliberately expose writes later, there is now one configuration to review. Delete protection also prevents me from removing the card while either agent refers to it.
A tool that belongs to only one agent could stay inline with a __model__ tag. A whole shared
ToolCard deserves its own entry and a normal reference.
Turn each agent card into an entry#
An agent entry is the old AgentCard constructor written as data. Shared Python objects become
references; local configuration stays inline:
catalog.create(
Entry(
id="case-triage",
kind="agent",
namespace=NAMESPACE,
user_id=OWNER,
model_type="akgentic.core.AgentCard",
description="Judge how urgent a case is and propose a priority.",
payload={
"description": "Judge how urgent a case is and propose a priority.",
"skills": ["triage"],
"agent_class": "basic_akgents.case_triage.CaseTriageAgent",
"config": {
"name": "@CaseTriage",
"role": "Triage",
"prompt": {
"template": {"__ref__": "triage-prompt-body"},
"params": {},
},
"model_cfg": model_ref,
"run_usage_limits": {
"run_request_limit": 10,
"tool_calls_limit": 10,
},
"agent_usage_limits": {
"agent_request_limit": 40,
"total_tokens_limit": 200_000,
},
"tools": [case_tool_ref],
},
},
)
)case-executor has the same model and tool references, its own prompt reference, and its own class
path. case-coordinator and case-repository need no model, prompt, or tool. Their catalog entries
remain small because their behaviour is deterministic.
CaseRepositoryConfig.backend is a dotted-path string, not a live repository object. The actor
resolves it in on_start(), so the catalog can store the name safely. The same is true for the
human I/O implementation in CliUserProxyConfig.
This gave me a sharper boundary than “configuration goes in the catalog, objects stay in Python.” A serializable card or dotted path can live in the catalog. A live connection, open file, client, or other runtime handle belongs behind the card and is created when the runtime starts.
Wire the team with references#
After all five agent entries exist, I replace the bootstrap card and write the member tree:
team = catalog.get(NAMESPACE, TEAM_ID)
entry_point = {
**team.payload["entry_point"],
"card": {"__ref__": "user-proxy"},
}
members = [
{
"card": {"__ref__": "case-coordinator"},
"members": [
{"card": {"__ref__": "case-triage"}},
{"card": {"__ref__": "case-repository"}},
{"card": {"__ref__": "case-executor"}},
],
}
]
catalog.update(
team.model_copy(
update={
"payload": {
**team.payload,
"entry_point": entry_point,
"members": members,
}
}
)
)The stored team entry now describes structure and points to the cards. Each agent entry owns its configuration and points to shared pieces. The graph is explicit enough to query, protect, export, and render.
Hand the result to the unchanged runtime#
The payoff is one line:
team_card = catalog.load_team(NAMESPACE)team_card is a fully populated, typed TeamCard. From CaseRunner’s point of view, the old
constructor and the catalog loader are interchangeable:
runtime = self._manager.create_team(
team_card=case_team_card_from_catalog(),
user_id=requester_id,
metadata=CaseMetaData(case_id=case_id),
)TeamManager, the agent classes, the workflow messages, the human approval gates, the event store,
and the console do not need catalog-specific behaviour. The runtime consumes its normal model. Only
the source of that model changed.
Editing data without changing the runtime#
The seed code is still Python, which may look like it defeats the goal. It does not. The seed is the
initial value for a local demo. Once written, the durable YAML is the source that
catalog.load_team() reads on every case.
I can export the namespace, edit a prompt body or model configuration, validate it, and import it
back without changing CaseRunner or any agent. The seed only runs again after I remove the local
catalog, for example through make clean-data.
In a larger system, a deployment pipeline, catalog CLI, REST API, or administration UI would own the entries. The application would keep only the load. The sample deliberately shows both sides in one place so you can see the complete lifecycle without adding infrastructure first.
That is also why storage and runtime remain separate. A stopped team keeps the exact TeamCard it
was created from in the event store. Editing the catalog affects teams created on the next load; it
does not rewrite the history or identity of a team that already ran.
Run and inspect the sample#
Restore the locked environment and run the quality checks from the sample repository:
git clone https://github.com/jettro/basic-akgents.git
cd basic-akgents
uv sync
make check
uv run pytestStart the application and handle a case:
make run CASE=case_2On the first run, data/catalog/case-handling-team/ is seeded. Later runs reuse it. The application
still asks for human approval of the proposed priority and the execution plan; the catalog changes
how the team is described, not what it does.
You can inspect the generated YAML, change the stored model or a prompt, and run another case. Keep the schema intact: the catalog validates the payload again when it resolves the team.
To reset all local runtime data, including the catalog and event store:
make clean-dataThe next run seeds the known configuration again.
A learning path through the package#
The package examples are deliberately incremental. This is the order I found useful:
- Start with entry creation, reading, updating, deletion, and the namespace anchor rule.
- Add references and inspect both the stored marker and the resolved model.
- Use
NativeValuefor a shared scalar. - Export, validate, and import a namespace bundle.
- Run the same operations against another repository backend.
- Register a narrow application prefix and store a custom model type.
- Only then port a real
TeamCard, comparing every field with the Python original.
For the last step, I recommend a temporary scratch catalog first. Print the resolved TeamCard and
compare its serialized shape with case_team_card(). Once the shapes match, move the code to a
fixed namespace and durable repository, then add the seed guard and failure cleanup.
That sequence separates two different problems: learning the catalog contract and integrating it into a process that runs repeatedly.
Gotchas I would keep beside the code#
The happy path is small. The edges are where the refactor became instructive:
- A namespace is one team boundary, not just a folder name. Ids are unique across all kinds inside it.
- Create a team or
_metaanchor before adding sub-entries. - Keep
user_ididentical across the namespace; it is an enforced ownership label. - A
__ref__dictionary is a pure pointer. Put varying fields in the parent model, not beside the marker. - Use
NativeValuefor a shared scalar, not for structured data that deserves a model. agent_classmay be a plain dotted-path string, while realtypefields need a__type__tag.- Add
__model__when a polymorphic nested model cannot be inferred from its base type. - Keep the
model_typeallowlist narrow and initialize it in every process that resolves custom entries. - A missing field with a default can be valid and still be wrong. Compare the port field by field.
- Do not seed with
UNSET_NAMESPACEon every request. Use a fixed namespace if you need to find it again. createis not idempotent. Separate the one-time write path from the repeatable load path.- Roll back a failed multi-entry seed, or use an atomic bundle import.
- A reference cycle may fail on resolve rather than on the write that completed it.
- Catalog configuration should name or describe runtime resources, not hold live connections.
- Changing the catalog affects newly loaded teams; it does not mutate the persisted history of a team that already ran.
None of these rules is unusual on its own. Together they turn configuration into something I can trust rather than a loosely typed alternative to Python.
Where the catalog fits in Akgents#
This seventh article completes another part of the framework map.
flowchart TB
INFRA["akgentic-infra
deployment and service surfaces"]
CATALOG["akgentic-catalog
validated, persistent team configuration"]
AGENT["akgentic-agent
LLM-powered actors"]
TEAM["akgentic-team
team cards, lifecycle, event persistence"]
TOOL["akgentic-tool
reusable capabilities"]
LLM["akgentic-llm
model providers and ReAct loop"]
CORE["akgentic-core
actors, messages, state, event stream"]
INFRA --> CATALOG
INFRA --> AGENT
INFRA --> TEAM
CATALOG --> TEAM
CATALOG --> TOOL
CATALOG --> LLM
AGENT --> TOOL
AGENT --> LLM
AGENT --> CORE
TEAM --> CORE
TOOL --> CORE
The catalog sits above the models it stores and in front of the runtime that consumes them. It makes teams configurable without taking responsibility for running them. The next infrastructure layer can expose those catalog operations through services and user interfaces without forcing the actors to understand where their configuration came from.
That separation is consistent with the rest of the series. Core owns messaging. Team owns lifecycle. LLM owns model runs. Tool owns capabilities. Agent composes those runtime pieces. Catalog owns persistent configuration.
What I take from the refactor#
I started this exercise thinking primarily about moving a large constructor into YAML. I ended with a more useful mental model: the catalog gives configuration identity.
A model is not merely the same dictionary copied into two agents; it is one entry with two referrers. A tool is not an identical constructor repeated twice; it is a reusable card with delete protection. A prompt body is not hidden inside an agent; it is a value that can be changed and reviewed independently. A team is not only a Python return value; it is a namespace that can be validated, exported, cloned, and restored through another backend.
The strongest part of the design is what did not change. TeamManager still receives a TeamCard.
The case workflow still uses typed messages and deterministic routes. The two LLM agents still get
read-only tools. A human still approves both decisions. Configuration became external without
leaking catalog mechanics into the running team.
That is when I would reach for akgentic-catalog: not because data is inherently better than code,
but because a team has reached the point where its configuration needs its own lifecycle, identity,
validation, and storage boundary.




