Skip to content

Add a New Agent Kind

How to add a new conversational agent to the Backend API. A kind is one agent behavior — its prompt, tools and structured output — plugged into the generic agent framework. Concepts first: Agent Sessions.


Architecture overview

chat message
  └─▶ chat lifecycle hook
        └─▶ AgentSessionService (start / drain)
              └─▶ transport (chat | sync)
              └─▶ runner (one LLM turn)
                    └─▶ AgentSpec   ◀── you implement this

Key locations

Path Purpose
app/constants/agents.py Closed enums: family, kind, composed spec id, transport type, memory scope
app/services/agents/base.py The seam: AgentSpec, AgentState, and every optional Supports* capability
app/services/agents/registry.py family.kind → spec registry
app/services/agents/implementations/kinds/ Concrete kinds (requirements_gathering/, assistant/)
app/services/agents/implementations/transports/chat/bindings.py Chat context → kind binding
app/services/agents/bootstrap.py ensure_registered() — the single registration entrypoint

The framework layer imports no implementations; bootstrap is the only exempt module, and a purity test fails the build if that rule is broken.


Step 1: Declare the ids

Add the family (if new), the kind, and the composed spec id. All three are StrEnum members over plain varchar columns — no migration is needed.

# app/constants/agents.py

class AgentFamily(StrEnum):
    REQUIREMENTS_GATHERING = "requirements_gathering"
    ASSISTANT = "assistant"

class AgentKind(StrEnum):
    SET_DESIGN_BRIEF = "set_design_brief"
    ORG_ASSISTANT = "org_assistant"
    MY_NEW_KIND = "my_new_kind"          # <-- new

class AgentSpecId(StrEnum):
    MY_NEW_KIND = f"{AgentFamily.REQUIREMENTS_GATHERING}.{AgentKind.MY_NEW_KIND}"

Step 2: Write the spec

For a gathering kind, subclass GatheringSpec and declare the contract — the base supplies the state lifecycle, the grounded system prompt, the two-turn finish handshake and the scope default.

# app/services/agents/implementations/kinds/requirements_gathering/my_kind.py

class MyBrief(BaseModel):
    palette: str
    reference_images: list[uuid.UUID] = Field(default_factory=list)


class MyKindSpec(GatheringSpec, MediaReferenceMixin):
    id = AgentSpecId.MY_NEW_KIND
    subject = "the colour brief for a shooting"
    greeting = "Tell me the palette you have in mind."
    output_model = MyBrief
    required = ("palette",)
    uuid_fields = ("reference_images",)

    # MediaReferenceMixin wiring (only if the kind gathers uploaded images)
    reference_image_field = "reference_images"
    reference_images_tool = "set_reference_images"

    def gathering_tools(self) -> list[Tool]:
        return [Tool(set_palette), Tool(set_reference_images)]


register(MyKindSpec())

Declarations are validated at class-definition time — a missing attribute, an output field that does not exist, or a media tool name that is not in tools() raises at import, not on the first live turn.

Tools mutate state, they do not touch the database

A tool receives the accumulator through the run context and records validated values on it. Anything that must hit the database is buffered on the state and applied by the service after the turn succeeds — a failed turn then persists nothing.


Step 3: Opt into the capabilities you need

Each capability is a small protocol satisfied by a plain method. A kind that does not declare one simply does not get that behavior.

Capability Gives you
SupportsMediaNote / SupportsReferenceMedia Attached-image handling and completion re-attachment (both come with MediaReferenceMixin)
SupportsScopeGate Custom topicality classification
SupportsOpenScope Skip topicality, keep a safety gate (open-topic kinds)
SupportsAwaitingAnswer Safety-only gating while the user is answering your question
SupportsTurnStart A hook before every LLM run
SupportsMemory Declare the durable-memory allowlist and its coercion
SupportsRuntimeBinding Receive per-turn runtime (organization id, memory service, subject access)
SupportsDynamicGreeting Open from what is already known instead of re-asking
SupportsCompletionMessage A deterministic closing message
SupportsMessageOptions / SupportsStructuredInputs Offer pressable options and apply a press deterministically
SupportsMemoryEffects / SupportsOptionLockEffects Buffer writes on the state, applied after a successful turn

Step 4: Register the module

Registration is explicit — importing the package is what populates the registry:

# app/services/agents/implementations/kinds/requirements_gathering/__init__.py
from . import my_kind  # noqa: F401  (registration side effect)

ensure_registered() is called from the API lifespan, the Celery tasks and the test fixtures. It is idempotent.


Step 5: Bind a chat context (optional)

To run the kind in a chat, add one line to the binding map:

# app/services/agents/implementations/transports/chat/bindings.py
CONTEXT_AGENT_BINDINGS = {
    ChatContextType.SET_DESIGN: AgentSpecId.SET_DESIGN_BRIEF,
    ChatContextType.ORG_ASSISTANT: AgentSpecId.ORG_ASSISTANT,
    ChatContextType.MY_RESOURCE: AgentSpecId.MY_NEW_KIND,   # <-- new
}

If the resource itself is new, add the chat context type and its validation entry first — see Chat.


Step 6: Test offline

Everything runs without network or credentials — inject a fake model into the turn:

turn = await run_turn(
    MyKindSpec(),
    state,
    history,
    [AgentInput(text="warm neutrals")],
    model=TestModel(),        # main model
    scope_model=TestModel(),  # the guardrail classifier
)

Cover at minimum: the happy gathering path to completion, the two-turn finish handshake, a rejected off-topic message, and — if the kind uses them — media recording and option locking.


Rules that are enforced, not suggested

  • No fastapi imports under app/services/agents/ — transports raise agent-domain exceptions and the route maps them to HTTP.
  • A transport never calls a service (e.g. the chat service) — that would reintroduce an import cycle and surrender the turn's transaction ownership. Depend on repositories.
  • Media is stored as ids, never URLs. Image URLs are stripped from the message history before it is persisted.
  • The framework never imports an implementation — a purity test enforces it.