↓ Skip to main content
  1. Blog/

Configure agent teams with Akgents ~ akgentic-catalog

Jettro Coenradie
Author
Jettro Coenradie
Software architect and search enthusiast. I write about AI, search, cloud, and software development.
Table of Contents
Akgents, a battle-tested agent framework - This article is part of a series.
Part 7: This Article

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:

ConcernOwner
Describe models, prompts, tools, agents, and teamsakgentic-catalog
Turn a description into actorsakgentic-team
Run actor mailboxes and typed messagesakgentic-core
Run the ReAct loopakgentic-llm
Build runtime tool surfacesakgentic-tool
Combine actor, LLM, and tool behaviourakgentic-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-catalog

The 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:

LayerFieldsPurpose
Catalog envelopeid, kind, namespace, user_id, descriptionIdentity, ownership, grouping, and queries
Model selectormodel_typeDotted path of the Pydantic class that defines the payload
Model datapayloadPlain 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 team entry, which is the domain description with an entry point, members, message types, and other TeamCard fields;
  • a _meta entry, 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:

  • shareable allows other namespaces to reference entries in this namespace;
  • public allows 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.ModelConfig

A 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.7

That 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 _meta anchor 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 declarationCatalog representation
Returned TeamCardOne team entry and the namespace anchor
Five AgentCard instancesFive agent entries
Shared CASE_MODELOne model entry, referenced by both LLM agents
Two prompt stringsTwo NativeValue prompt entries
Two identical CaseTool() valuesOne shared tool entry
Nested TeamCardMembersReferences 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 tool

What 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.yaml

These 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-executor

Opening 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.CaseTool

The 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.0

The 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 True

The 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 pytest

Start the application and handle a case:

make run CASE=case_2

On 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-data

The 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:

  1. Start with entry creation, reading, updating, deletion, and the namespace anchor rule.
  2. Add references and inspect both the stored marker and the resolved model.
  3. Use NativeValue for a shared scalar.
  4. Export, validate, and import a namespace bundle.
  5. Run the same operations against another repository backend.
  6. Register a narrow application prefix and store a custom model type.
  7. 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 _meta anchor before adding sub-entries.
  • Keep user_id identical 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 NativeValue for a shared scalar, not for structured data that deserves a model.
  • agent_class may be a plain dotted-path string, while real type fields need a __type__ tag.
  • Add __model__ when a polymorphic nested model cannot be inferred from its base type.
  • Keep the model_type allowlist 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_NAMESPACE on every request. Use a fixed namespace if you need to find it again.
  • create is 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.

Akgents, a battle-tested agent framework - This article is part of a series.
Part 7: This Article

Related