Metadata-Version: 2.5
Name: akgentic-catalog
Version: 2.2.7
Summary: Collaborative agent patterns for the Akgentic framework
Author-email: gpiroux <geoffroy.piroux@weareyuma.com>
License-Expression: AGPL-3.0-only
License-File: LICENSE
Keywords: ai,catalog,collaboration,pydantic
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.12
Requires-Dist: akgentic-core>=1.5.15
Requires-Dist: akgentic-team>=1.11.0
Requires-Dist: akgentic-tool>=1.11.0
Requires-Dist: pydantic>=2.0.0
Requires-Dist: pyyaml>=6.0
Provides-Extra: api
Requires-Dist: fastapi>=0.100.0; extra == 'api'
Requires-Dist: uvicorn>=0.20.0; extra == 'api'
Provides-Extra: cli
Requires-Dist: rich>=13.0.0; extra == 'cli'
Requires-Dist: typer>=0.9.0; extra == 'cli'
Provides-Extra: dev
Requires-Dist: akgentic-agent>=1.6.0; extra == 'dev'
Requires-Dist: akgentic-llm>=1.6.0; extra == 'dev'
Requires-Dist: httpx>=0.24.0; extra == 'dev'
Requires-Dist: mongomock>=4.0.0; extra == 'dev'
Requires-Dist: mypy>=1.8.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
Requires-Dist: pytest>=8.0.0; extra == 'dev'
Requires-Dist: ruff>=0.1.0; extra == 'dev'
Requires-Dist: testcontainers[postgres]>=4.0.0; extra == 'dev'
Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
Provides-Extra: mongo
Requires-Dist: pymongo>=4.0.0; extra == 'mongo'
Provides-Extra: postgres
Requires-Dist: nagra[pg]>=0.11; extra == 'postgres'
Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
Description-Content-Type: text/markdown

# akgentic-catalog

[![CI](https://github.com/b12consulting/akgentic-catalog/actions/workflows/ci.yml/badge.svg)](https://github.com/b12consulting/akgentic-catalog/actions/workflows/ci.yml)
[![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/gpiroux/35850b0665f1d1dd2402c43362ee4d35/raw/coverage.json)](https://github.com/b12consulting/akgentic-catalog/actions/workflows/ci.yml)

A serialisation / deserialisation layer for Pydantic models, with pluggable
persistence. You declare a model; the catalog validates a payload against it on
write and rehydrates it through the same model on read. Its `__ref__` pattern
lets a stored payload compose itself from other stored payloads **discovered by
name**, so a value lives in one place and is referenced rather than copied.

Built for the
[Akgentic](https://github.com/b12consulting/akgentic-framework) multi-agent
framework (open-source bundle) — teams, agents, tools, prompts and models are
the shapes it stores out of the box — but the storable set is open: any
Pydantic model whose dotted path is on the deployment's allowlist.

Entries are grouped into **namespaces**, and the usual unit is one agent team:
its team card, plus the agents, tools and prompts that team is built from. A
namespace is the boundary for referencing, for export and import, and for who
may see or copy it. A namespace need not define a team — a shared library
namespace holds only the models and tools that other namespaces reference.

## Table of Contents

- [Overview](#overview)
- [The round trip](#the-round-trip)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Architecture](#architecture)
- [The Entry Model](#the-entry-model)
- [Unknown keys are errors](#unknown-keys-are-errors)
- [Registering customer model types](#registering-customer-model-types)
- [Storage Backends](#storage-backends)
- [Sharing scalars between entries](#sharing-scalars-between-entries)
- [References Between Entries](#references-between-entries)
- [Querying the Catalog](#querying-the-catalog)
- [CLI](#cli)
- [REST API](#rest-api)
- [Development](#development)
- [License](#license)

## Overview

The catalog is one shape and one service. An `Entry` is addressed by the
compound key `(kind, namespace, id)` and carries a `payload` validated against
the Pydantic class its `model_type` names. A single `Catalog` service owns every
operation — create, query, clone, validate, resolve — and reaches storage
through a single `EntryRepository` protocol, so which database is behind it is a
deployment choice rather than a change to any calling code.

Key properties:

- **Unified `Entry` model** — one Pydantic shape for every kind of
  configuration. `kind` is a closed set of six — `team`, `agent`, `tool`,
  `prompt`, `model` and `meta` — while what an entry may *hold* is open: any
  Pydantic class whose `model_type` resolves through the configured prefix
  allowlist (`akgentic.` always, widenable per deployment).
- **One namespace, one agent team.** The namespace is the core organising
  principle, not a folder: it holds a team card plus the agents, tools, prompts
  and models that team is built from. `__ref__` resolution is namespace-bounded
  by default, export and import move a whole namespace at a time, and
  visibility is decided per namespace. Two teams are two namespaces.

  Two entries play distinct roles inside one. Either is enough to *bootstrap* a
  namespace — see the anchor invariant below — but they are not two ways of
  saying the same thing:
  - The **`team` entry defines the team** — it is domain content, the root the
    agents hang off. It is **optional**: a *shared library* namespace defines
    no team and exists purely to be referenced. The shipped `global` and
    `global_tools` namespaces are exactly this — model configurations and tools
    that every team's namespace points at, so they are declared once.
  - The **`_meta` entry carries the namespace's own parameters** — its display
    name, description, free-form `properties`, and the `shareable` / `public`
    flags below. It is technical rather than domain content, which is what the
    leading underscore in its reserved id `_meta` marks. On export it is hoisted
    into the bundle header rather than listed among the entries.

- **`shareable` and `public` are two different questions about a namespace**,
  both set on its `_meta` entry and both defaulting to `False`:
  - **`shareable`** — may entries in *other* namespaces reference into this
    one? A cross-namespace marker (`__namespace__`, or the `<ns>.<id>`
    shorthand) resolves only if the target namespace is `shareable: true`;
    otherwise the resolver refuses it. This is what lets a `global` namespace
    hold model configurations and tools that every team's namespace points at,
    instead of each team copying them.
  - **`public`** — may users who do not own this namespace see it and clone
    it? It governs listing and copying at the catalog boundary, and says
    nothing about references.

  They are independent: a namespace can be referenceable but not browsable, or
  browsable but closed to references. Ownership is a third, separate axis —
  `user_id` on the entry.
- **A ref marker is a pure pointer** — a payload embeds
  `{"__ref__": "<entry-id>"}` wherever it wants another entry's content,
  optionally alongside `__type__` and `__namespace__`. Those three keys are
  the whole vocabulary: any other key next to `__ref__` is a validation
  error. The resolver follows the markers (with cycle detection) to produce a
  fully-populated runtime object, and a `NativeValue` entry lets a bare scalar
  be shared the same way.
- **Pluggable storage** — YAML-file-per-entry, MongoDB single-collection and
  PostgreSQL single-table backends ship in the box behind the
  `EntryRepository` protocol.
- **Namespace bundles** — export/import every entry in a namespace as a
  single YAML document for round-tripping between environments.
- **CLI and REST API** — manage entries and bundles outside of Python.

## The round trip

The catalog stores Pydantic models, not free-form documents. Declare the model
once, anywhere importable, deriving from the framework's
`SerializableBaseModel`:

```python
from akgentic.core.utils import SerializableBaseModel

class CaseIngestionConfig(SerializableBaseModel):
    source: str
    batch_size: int = 100
```

`SerializableBaseModel` is the base every catalog model should use, and the base
the framework's own shapes already use — `AgentCard`, `ToolCard` and the team
cards all derive from it. It stamps a `__model__` tag carrying the class's
dotted path into each dump and strips it again on load, which is what lets a
**polymorphic** field recover its concrete subclass: a `list[ToolCard]` payload
rehydrates as the real `SearchTool` and `WorkspaceTool` instances rather than as
the abstract base. For a field typed as a concrete class a plain `BaseModel`
happens to work — `akgentic.llm.PromptTemplate` is one — but it will not survive
being referenced from a polymorphic position, which is why the rule is stated as
one base rather than two cases.

An entry names it in `model_type` and carries its data in `payload`. On write
the payload is validated against that class — a key the model does not declare
is an error, never a silent drop — and on read it is rehydrated through the same
class. Where a payload wants a value that already lives somewhere else, it
writes a `__ref__` marker instead of a copy, and the resolver splices the target
in at load time:

```python
from akgentic.catalog import Entry

# `catalog` is a Catalog instance — Quick Start below shows how to build one.
# The shared value lives in one entry, so every consumer reads one number.
catalog.create(Entry(
    id="default-batch-size",
    kind="model",
    namespace="acme-prod",
    user_id="u1",
    model_type="akgentic.catalog.NativeValue",
    payload={"value": 200},
))

catalog.create(Entry(
    id="case-ingestion",
    kind="tool",
    namespace="acme-prod",
    user_id="u1",
    model_type="acme.core.models.CaseIngestionConfig",
    payload={"source": "sftp", "batch_size": {"__ref__": "default-batch-size"}},
))

config = catalog.resolve_by_id("acme-prod", "case-ingestion")
config.batch_size   # 200 — a CaseIngestionConfig, with the ref spliced in
```

Both writes assume `acme-prod` is already anchored by a `team` or `_meta` entry,
and that `acme.core.models.` has been added to the `model_type` allowlist — see
[Registering customer model types](#registering-customer-model-types). That is
the whole contract: your model is the schema, the entry is the row, and a
`__ref__` is how one row reuses another.

## Installation

Published on PyPI. Python 3.12 or newer.

```bash
uv add akgentic-catalog
# or
pip install akgentic-catalog
```

That is the whole install. `akgentic-core`, `akgentic-llm`, `akgentic-tool` and
`akgentic-team` come with it as ordinary dependencies — no workspace checkout,
no submodules.

### Optional Extras

The base install gives you the `Catalog` service and the YAML backend. Each
extra adds one optional surface:

| Extra      | Packages pulled in         | Enables                              |
|------------|----------------------------|--------------------------------------|
| `api`      | `fastapi`, `uvicorn`       | `create_app()` FastAPI factory       |
| `cli`      | `typer`, `rich`            | `ak-catalog` console script          |
| `mongo`    | `pymongo`                  | `MongoEntryRepository`               |
| `postgres` | `nagra`, `psycopg[binary]` | `PostgresEntryRepository`, `init_db` |

```bash
uv add "akgentic-catalog[cli]"
uv add "akgentic-catalog[api,postgres]"
```

An optional backend is imported lazily, so importing `akgentic.catalog` without
`pymongo` or `psycopg` installed is fine — you only need the extra for the
backend you actually construct.

### As part of the framework bundle

`akgentic-framework` is the meta-distribution that pins every akgentic package
at versions built and tested together. Install `akgentic-catalog` through it
when you want the release-wide pin rather than a single package:

```bash
pip install "akgentic-framework[catalog]"   # this package + its closure, release-pinned
pip install "akgentic-framework[all]"       # the whole framework
```

### Working on the package itself

To develop `akgentic-catalog` rather than use it, clone the open-source bundle
[akgentic-framework](https://github.com/b12consulting/akgentic-framework), which
carries every package together as submodules:

```bash
git clone git@github.com:b12consulting/akgentic-framework.git
cd akgentic-framework
git submodule update --init
# uncomment the two "SOURCE MODE" blocks in pyproject.toml
uv sync
```

Source mode resolves `akgentic-*` to the local checkouts, editable.

## Quick Start

Create a fresh YAML-backed catalog, seed a team namespace, and resolve it:

```python
import tempfile
from pathlib import Path

from akgentic.catalog import (
    Catalog,
    Entry,
    UNSET_NAMESPACE,
    YamlEntryRepository,
)

with tempfile.TemporaryDirectory() as tmp:
    repo = YamlEntryRepository(Path(tmp))
    catalog = Catalog(repo)

    # 1. The team entry anchors the namespace, so it must be self-contained:
    #    its entry_point card is inline, because nothing exists to point at yet.
    lead_card = {
        "description": "Coordinates the team",
        "skills": ["coordination"],
        "agent_class": "akgentic.agent.BaseAgent",
        "config": {"name": "@Lead", "role": "Lead"},
    }
    team = catalog.create(Entry(
        id="research-team",
        kind="team",
        namespace=UNSET_NAMESPACE,
        user_id="u1",
        model_type="akgentic.team.models.TeamCard",
        payload={
            "name": "Research Team",
            "entry_point": {"card": lead_card, "headcount": 1, "members": []},
            "members": [],
        },
    ))                                   # namespace replaced by a fresh UUID
    namespace = team.namespace

    # 2. Give that agent an entry of its own in the same namespace.
    catalog.create(Entry(
        id="lead-agent",
        kind="agent",
        namespace=namespace,
        user_id="u1",
        model_type="akgentic.core.AgentCard",
        payload=lead_card,
    ))

    # 3. Point the team at it — the card is now referenced, not duplicated.
    #    The marker sits at `card`: `entry_point` is a TeamCardMember, and only
    #    its `card` field is an AgentCard.
    entry_point = {**team.payload["entry_point"]}
    entry_point["card"] = {"__ref__": "lead-agent", "__type__": "akgentic.core.AgentCard"}
    team = catalog.update(
        team.model_copy(update={"payload": {**team.payload, "entry_point": entry_point}})
    )

    # 4. Read / resolve.
    stored_team = catalog.get(namespace=namespace, id="research-team")
    team_card = catalog.load_team(namespace)  # TeamCard with refs populated
```

See the architecture shards for a namespace-bundle walkthrough and YAML
authoring guidance.

## Architecture

```mermaid
flowchart LR
    PY[Python API] --> CAT
    CLI[ak-catalog CLI] --> CAT
    API[FastAPI /catalog] --> CAT
    CAT[Catalog service] --> RES[resolver.py]
    CAT --> REPO[EntryRepository]
    REPO --> YAML[(YamlEntryRepository)]
    REPO --> MONGO[(MongoEntryRepository)]
    REPO --> POSTGRES[(PostgresEntryRepository)]
```

The runtime layout under `src/akgentic/catalog/` mirrors shard 10:

```
src/akgentic/catalog/
    __init__.py          Public API (Catalog, Entry, EntryKind, EntryQuery, ...)
    catalog.py           Unified Catalog service (CRUD + clone + resolve + load_team)
    refs.py              Ref-marker shape and the one payload walk (leaf module)
    model_types.py       Allowlisted model loader + model-type enumeration
    resolver.py          Two-phase ref resolver (re-exports refs + model_types)
    env.py               ${VAR} substitution for YAML payloads
    serialization.py     Namespace bundle load/dump
    validation.py        Namespace-level validation report
    models/              Entry, EntryKind, EntryQuery, CloneRequest, errors
    repositories/        EntryRepository protocol + YAML + Mongo + Postgres impls
    api/                 FastAPI app + /catalog router
    cli/                 Typer ak-catalog app
```

### Layered invariants (enforced by `Catalog`)

- **Namespace bootstrap** — every other entry requires a pre-existing anchor
  in the same namespace: a `team` entry or a `_meta` entry. Anchors skip the
  check themselves, since they are what bootstraps the namespace.
- **Namespace minting** — creating a team with `namespace=UNSET_NAMESPACE`
  mints a fresh UUID before any other pipeline step runs.
- **Ownership propagation** — every sub-entry inherits the team's `user_id`.
- **Delete guards** — deleting an entry referenced by another entry in the
  same namespace raises `CatalogValidationError` listing inbound referrers.
- **Clone atomicity** — `clone` collects every intended write in memory and
  emits them in a single pass; partial failures leave the destination
  untouched.

## The Entry Model

Every catalog row is an `Entry`:

```python
from akgentic.catalog import Entry

Entry(
    id="lead-agent",              # stable within (kind, namespace)
    kind="agent",                  # team | agent | tool | prompt | model | meta
    namespace="tenant-42",         # tenancy / environment boundary
    user_id="u1",                  # ownership; propagated from the team
    model_type="akgentic.core.AgentCard",  # allowlisted Pydantic class
    payload={                      # validated against that class on write
        "description": "Coordinates the team",
        "skills": ["coordination"],
        "agent_class": "akgentic.agent.BaseAgent",
        "config": {"name": "@Lead", "role": "Lead"},
    },
)
```

`kind` is written as a plain string: `EntryKind`, also exported from
`akgentic.catalog`, is the `Literal` alias of those six values — a type to
annotate your own code with, not an enum with members to reference.

`model_type` is a dotted path to a Pydantic `BaseModel` subclass whose
prefix is on the configured allowlist — `akgentic.` always, plus whatever
the deployment authorized (see
[Registering customer model types](#registering-customer-model-types)). The
resolver calls `akgentic.catalog.model_types.load_model_type` to materialize
it. Payloads validate against that class at create/update time.

## Unknown keys are errors

A key the declared `model_type` does not accept is a validation error — on
Validate and on Save alike — never a silent drop:

```yaml
model_type: akgentic.llm.ModelConfig
payload:
  provider: openai
  temperatur: 0.3   # unknown key 'temperatur' — not a field of akgentic.llm.ModelConfig
```

The rule holds at three levels: the payload body, as above; and the bundle's two
closed key sets — its root keys and each entry map's keys — whose messages name
what they expected (`bundle root has unknown key 'sharable' — expected one of:
description, entries, name, namespace, properties, public, shareable, user_id`).

A key next to `__ref__` is refused by a separate rule — a ref marker is a pure
pointer and takes no other keys. See [Sharing scalars between
entries](#sharing-scalars-between-entries).

**Deleting a key remains the supported way to reset a field to its default.**
An absent key and a misprinted one are different intents; only the second is an
error. The `__ref__` / `__type__` / `__namespace__` sentinels and the
`__model__` polymorphic tag are exempt at every depth.

Two known gaps. `Catalog.clone` copies a source payload byte-for-byte and
bypasses this gate, so a cloned entry can still carry an unknown key and fails
on its first save. And entries stored before this change still read and
resolve — they fail on their next write, and a bundle file carrying a stale
root or entry-map key fails on its next import — so `ak-catalog validate`
across your namespaces is the way to find them first.

## Registering customer model types

`model_type` prefixes are a deployment policy. `akgentic.` is always
allowed and is never removable; configuration only widens the set. Point
`AKGENTIC_CATALOG_MODEL_TYPE_PREFIXES` at your own namespace — as a
comma-separated list or a JSON array, both parse identically — and catalog
entries may name your classes:

```bash
# Comma-separated — or, equivalently, a JSON array:
export AKGENTIC_CATALOG_MODEL_TYPE_PREFIXES=acme.core.models.,contoso.models.
export AKGENTIC_CATALOG_MODEL_TYPE_PREFIXES='["acme.core.models.","contoso.models."]'
```

```yaml
id: case-ingestion
kind: tool
namespace: acme-prod
user_id: anonymous
model_type: acme.core.models.CaseIngestionConfig
description: Case ingestion settings
payload: { source: sftp, batch_size: 200 }
```

**Prefer the narrowest prefix** — `acme.core.models.`, not `acme.`: every
module under an allowed prefix becomes something a catalog entry can cause
to be imported, so a prefix is a blast radius, not just a gate. **Give
every process the same value** — server, worker, and CLI — or one process
will accept an entry another refuses to resolve. The setting is
startup-only, process-wide, and never reachable from the HTTP surface;
`set_allowed_prefixes(["acme.core.models."])` from `akgentic.catalog`,
called during startup wiring before the first `Entry` is constructed or
resolved, is the in-code equivalent.

## Storage Backends

### YAML (default)

`YamlEntryRepository(root)` lays out one file per entry, namespaced
directory per namespace, partitioned by kind:

```
<root>/
  <namespace>/
    team/research-team.yaml
    agent/lead-agent.yaml
    tool/web-search.yaml
```

```python
from pathlib import Path

from akgentic.catalog import Catalog, YamlEntryRepository

catalog = Catalog(YamlEntryRepository(Path("./catalog")))
```

### MongoDB

`MongoEntryRepository` stores every entry in a single collection indexed by
the compound `(kind, namespace, id)` key. It takes a live
`pymongo.Collection`, not the config — the config is the thing that builds
one, and the repository owns neither the client nor the collection lifecycle.
Install the `mongo` extra and wire the chain:

```python
from akgentic.catalog import Catalog, MongoCatalogConfig, MongoEntryRepository

cfg = MongoCatalogConfig(
    connection_string="mongodb://localhost:27017",
    database="akgentic",
)
client = cfg.create_client()
collection = cfg.get_collection(client, cfg.catalog_entries_collection)
catalog = Catalog(MongoEntryRepository(collection))
```

### PostgreSQL

`PostgresEntryRepository` stores every entry in a single `catalog_entries`
table keyed by the compound `(namespace, id)` primary key. Install the
`postgres` extra and provide a DSN via one of three supply channels
(flag-wins precedence on the CLI, explicit kwarg on the API factory):

| Supply channel                              | Consumer              |
|---------------------------------------------|-----------------------|
| `PostgresCatalogConfig(connection_string=)` | `create_app()` factory |
| `--postgres-conn-string` CLI flag           | `ak-catalog`          |
| `DB_CONN_STRING_PERSISTENCE` env var        | CLI + init-container  |

```python
from akgentic.catalog import Catalog
from akgentic.catalog.api.app import create_app
from akgentic.catalog.repositories.postgres import (
    PostgresCatalogConfig,
    PostgresEntryRepository,
)

# Programmatic / API path.
cfg = PostgresCatalogConfig(
    connection_string="postgresql://postgres:pw@localhost:5432/catalog",
)
app = create_app(backend="postgres", postgres_config=cfg)

# Direct repository path.
catalog = Catalog(PostgresEntryRepository(cfg.connection_string))
```

> **Deployment prerequisite.** The `PostgresEntryRepository` constructor
> does NOT create the schema — it only validates the DSN. Before starting
> the catalog service against a fresh database, run the runnable
> init-container module once per environment (Kubernetes `initContainer`
> / Nomad `prestart` pattern):
>
> ```bash
> DB_CONN_STRING_PERSISTENCE=postgresql://postgres:pw@localhost:5432/catalog \
>   python -m akgentic.catalog.scripts.init_db
> ```
>
> Exit code `0` on success, `2` when the env var is missing, `1` on any
> other failure (unreachable host, malformed DSN, driver error).

All three backends expose the same `EntryRepository` protocol; parity
tests under `tests/repositories/test_entry_repository_contract.py` keep
them interchangeable.

### Writing a backend

`EntryRepository` is a `typing.Protocol`, so any class with the right members
satisfies it structurally — but every backend in this package declares it as an
explicit base, and a backend you write must do the same:

```python
from akgentic.catalog.repositories.base import EntryRepository

class RedisEntryRepository(EntryRepository):   # never `class RedisEntryRepository:`
    ...
```

The base costs nothing at runtime. It makes the dependency visible to AST
tooling — import graphs, impact analysis, dead-code sweeps — instead of
inferable only by comparing member names. mypy checks every method's signature
against the contract at the class definition, not at the one call site that
happens to pass the instance. And a method the backend forgot leaves the class
abstract, so mypy rejects its instantiation. The protocol is not
`@runtime_checkable`: `isinstance(repo, EntryRepository)` raises `TypeError`.

## Sharing scalars between entries

When several entries need to reuse the same bare scalar — a prompt body, a
default role label, a model id — the catalog ships a sanctioned wrapper
called `NativeValue`. A `NativeValue` entry carries a single `value` field;
the resolver unwraps the value at the ref-splice site so a typed `str` /
`int` / `bool` field on the consuming entry receives the bare scalar
instead of the wrapper.

**This is the only way to share a bare scalar between entries.** Sharing a
whole model — a `ModelConfig`, a tool — needs no wrapper: a plain `__ref__` at
the consuming field does it, as `model_cfg` does below. What a ref marker
cannot do is carry anything else: it is a pure pointer — `__ref__` plus,
optionally, `__type__` / `__namespace__`, and any other key is a validation
error. So the consumer inlines its own payload and pulls the shared parts in,
rather than pointing at a whole entry and patching it.

The shipped `agent-team` catalog is the worked example — one template body,
three agents, each with its own parameters:

```yaml
# data/catalog/agent-team/prompt/id_team_template.yaml
id: id_team_template
kind: prompt
namespace: agent-team
user_id: anonymous
model_type: akgentic.catalog.NativeValue
description: Shared system-prompt template body for team members
payload:
  value: "You are a helpful {role}. \n{instructions}"

# data/catalog/agent-team/agent/expert.yaml  (payload.config excerpt)
    prompt:
      template:
        __ref__: id_team_template     # resolves to str — shared
      params:                         # inline — differs per agent
        role: expert
        instructions: Provide deep specialized knowledge.
    model_cfg:
      __ref__: global.id_gpt_41       # a whole model — plain ref, no wrapper
```

Two things are worth pinning explicitly:

- **The resolver unwraps `.value` at ref-splice time.** From every other
  layer's perspective — repositories, CLI, HTTP, bundle export — a
  `NativeValue` entry is a normal entry with a `{"value": <scalar>}`
  payload. The unwrap fires only when a `__ref__` marker targets a
  `NativeValue`; direct retrieval via `Catalog.get` returns the `Entry`
  like any other entry.
- **`NativeValue.value: dict[str, Any]` is for JSON literals at boundaries,
  NOT for structured catalog content.** Storing a typed structure under
  `value` as an untyped dict effectively bypasses the "payload is a
  `BaseModel`" invariant — the consuming side has nothing to validate
  against. If you need typed structured content, write a real
  `BaseModel`. The catalog does not mechanically block this anti-pattern;
  the discipline is on the catalog author.

See `_bmad-output/akgentic-catalog/decisions/adr-15-native-value-refs.md`
for the full rationale and design.

## References Between Entries

Entries are embedded in one another as sentinel ref markers, not by plain ID
strings. A marker is a **pure pointer**, and its keys are exactly:

```python
{"__ref__": "<entry-id>", "__type__": "<model_type>", "__namespace__": "<namespace>"}
```

Only `__ref__` is required; `__type__` pins the expected model and
`__namespace__` targets an entry outside the current namespace. **Any other key
beside `__ref__` is a `CatalogValidationError`** — a marker takes no overrides,
so a consumer that needs to vary something inlines its own payload and refs only
the shared part. The constants `REF_KEY`, `TYPE_KEY` and `NAMESPACE_KEY` are
re-exported from `akgentic.catalog` for construction and inspection.

Every walker treats a marker as a **leaf**: there is nothing inside it to
descend into. The resolver works in two phases — `populate_refs` (every marker
resolves to a known entry) and `resolve` (materializes the runtime Pydantic
object) — with cycle detection. See `architecture/05-validation.md` and
`architecture/06-service-and-env.md` for the full rules.

## Querying the Catalog

`EntryQuery` is the single query model for all kinds. Any subset of
filters may be provided; unspecified filters are ignored.

```python
from akgentic.catalog import EntryQuery

# Every entry in a namespace.
catalog.list_by_namespace("tenant-42")

# Cross-namespace filter.
catalog.list(EntryQuery(kind="agent", user_id="u1"))

# Description substring search.
catalog.list(EntryQuery(kind="tool", description_contains="search"))
```

## CLI

The optional `ak-catalog` console script (enabled by `--extra cli`)
mounts a Typer app with one subcommand group per kind plus top-level
verbs for namespace-scoped and schema operations.

```bash
# Kind-scoped CRUD.
ak-catalog --root ./catalog team list --namespace tenant-42
ak-catalog --root ./catalog agent get --namespace tenant-42 lead-agent
ak-catalog --root ./catalog agent create ./lead-agent.yaml

# Namespace bundle round-trip.
ak-catalog --root ./catalog export --namespace tenant-42 > tenant-42.yaml
ak-catalog --root ./catalog import ./tenant-42.yaml

# Validation & schema.
ak-catalog --root ./catalog validate --namespace tenant-42
ak-catalog --root ./catalog validate ./tenant-42.yaml   # dry-run from bundle
ak-catalog schema akgentic.core.AgentCard
ak-catalog model-types                                  # list allowlisted types
```

Full reference: [docs/cli-usage-guide.md](https://github.com/b12consulting/akgentic-catalog/blob/master/docs/cli-usage-guide.md).

## REST API

The optional FastAPI app (enabled by `--extra api`) mounts the `/catalog`
router. Start it on the default YAML backend:

```bash
uvicorn "akgentic.catalog:create_app" --factory
```

Backend is a `create_app` keyword, not an environment variable: `backend="yaml"`
(the default), `"mongodb"` or `"postgres"`, with `mongo_config` /
`postgres_config` supplying the connection for the latter two. Note the
spelling — the factory takes `"mongodb"` where the CLI's flag is
`--backend mongo`. Since the bare `--factory` form above passes no arguments,
anything other than the default needs a factory of your own to point uvicorn at:

```python
from akgentic.catalog import MongoCatalogConfig, create_app

def app_factory():
    return create_app(
        backend="mongodb",
        mongo_config=MongoCatalogConfig(
            connection_string="mongodb://localhost:27017",
            database="akgentic",
        ),
    )
```

Error responses map catalog exceptions to HTTP:

| Status | Cause                                   |
|--------|-----------------------------------------|
| `404`  | `EntryNotFoundError`                    |
| `409`  | `CatalogValidationError`                |
| `422`  | Pydantic `ValidationError` on payload   |

See `src/akgentic/catalog/api/router.py` for the full endpoint surface
(CRUD per kind, namespace bundle export/import, schema, resolve, validate).

## Development

### Prerequisites

- Python 3.12+
- [uv](https://docs.astral.sh/uv/) package manager

### Setup

```bash
uv sync --all-extras
```

### Commands

```bash
# Run tests
uv run pytest tests/

# Run tests with coverage
uv run pytest tests/ --cov=akgentic.catalog --cov-fail-under=80

# Lint
uv run ruff check src/ tests/

# Format
uv run ruff format src/ tests/

# Type check
uv run mypy src/
```

## License

This project is licensed under the [GNU Affero General Public License v3.0 (AGPL-3.0)](https://github.com/b12consulting/akgentic-catalog/blob/master/LICENSE).

> **Dual licensing & CLA** — Akgentic is available under the AGPL-3.0 open-source license. A commercial license is also planned for organizations that require alternative terms. Contact [Yuma](https://www.weareyuma.com/en/contact) for more information. External contributions will be accepted once a Contributor License Agreement (CLA) is in place. Until then, please hold off on submitting pull requests.
