Skip to main content
  1. Blog/

Build durable agent teams with Akgents ~ akgentic-team

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 2: This Article

From an actor tree to a managed team
#

In the first episode of this series, I built a small team on akgentic-core. A coordinator asked triage to assess a case, a repository agent owned the case data, and a user proxy brought the proposal to a person for approval. That exercise gave me the essential building blocks: actors, messages, configuration, state, an orchestrator, and a clear owner for mutable data.

The agents already formed a team in the everyday sense, but the application still assembled and managed that team itself. It created the orchestrator, spawned every agent, connected them, waited for the result, and shut everything down. That works for learning the core. It becomes more difficult when a team must be described consistently, found again after it stops, or reconstructed after the process that hosted it is gone.

This is where the akgentic-team module comes in. It does not change what the case agents do. It adds a lifecycle around them: a declarative team card, a runtime handle, typed business metadata, persistence, subscribers, and operations for creating, stopping, resuming, and deleting teams. The sample is still one case handled by four agents, but it now demonstrates how the framework can manage that team as a durable unit.

One case, one team
#

The most important design decision did not change: one case gets one lightweight team. The team is created when work starts and stopped when that case reaches an outcome. It is not a permanent group that handles unrelated requests forever.

That short lifetime does not make the team disposable. A stopped team keeps its identity, description, metadata, event history, and the last known state of every agent. The actors no longer run, but the team remains available for inspection and can be resumed later. This separation between a live runtime and a durable team record is the central idea of this episode.

flowchart LR
    REQ["Case request"] --> CREATE["Create one team"]
    CREATE --> RUN["RUNNING: actors handle the case"]
    RUN --> STOP["STOPPED: actors are gone, history remains"]
    STOP -->|"fresh request"| RUN
    STOP --> DELETE["DELETED: persisted data is removed"]

You can follow the evolving example in the basic-akgents sample repository. As with the first episode, this articles state is kept at its own branch so the code stays aligned with the text while the sample continues to grow. You can checkout the branch blog_part_2

Add the teams module
#

The sample adds akgentic-team alongside the core package:

uv add akgentic-team

The project currently does not use the optional CLI dependency, you can use it to work with the system from the command line. I decided to create something basic myself to keep it as simple as possible:

uv add "akgentic-team[cli]"

The application has its own small console, but that is not the subject of this article. Its useful role here is to make the framework visible: it can list stored teams, show their state and events, and resume one. The concepts behind those commands are the interesting part.

Describe the team with cards
#

In the core-only version, Python code created agents one at a time. With akgentic-team, a TeamCard is the source of truth for the structure. Each role gets an AgentCard, and TeamCardMember objects arrange those cards into a tree.

The case team contains the same four agents as before:

flowchart TD
    EP["@UserProxy
entry point"] CO["@CaseCoordinator
supervisor"] TR["@CaseTriage"] RE["@CaseRepository"] EP -. "sends external request" .-> CO CO --> TR CO --> RE

The corresponding team declaration is:

return TeamCard(
    name="case-handling-team",
    description="Handles cases when requested using an id.",
    entry_point=TeamCardMember(card=human_proxy_card),
    members=[
        TeamCardMember(
            card=coordinator_card,
            members=[
                TeamCardMember(card=triage_agent_card),
                TeamCardMember(card=repository_agent_card),
            ],
        ),
    ],
    message_types=[HandleCaseRequest],
    metadata_type=CaseMetaData,
    welcome_message="Case team ready to handle your case.",
)

There are a few important framework concepts in this small declaration.

The entry point represents the team at its external boundary. TeamRuntime.send() uses the entry point as the sender and routes the message to the first layer of regular members. In this team that first layer contains only @CaseCoordinator, so the coordinator receives the HandleCaseRequest. Triage and the repository are deeper members of the coordinator’s subtree and are not external targets.

The message_types list declares what the team accepts. Passing an existing Message to runtime.send() preserves that message. Passing a plain string makes the runtime wrap it in the first declared message type. I prefer constructing HandleCaseRequest explicitly because its requester_id communicates the contract more clearly than a generic string.

The tree also records parent-child relationships. TeamFactory creates the orchestrator first, creates the entry point and top-level members through it, and recursively creates children through their parent. The framework therefore propagates the orchestrator, team id, and parent relationship without the application reproducing the startup sequence from episode one.

Put runtime context in typed team metadata
#

The team card is static: every case team has the same roles and structure. The case id is different for every run, so it no longer belongs in every agent’s configuration. It is business context for one instance of the team.

akgentic-team models that distinction with typed metadata:

from akgentic.team import TeamMetadata
from pydantic import Field


class CaseMetaData(TeamMetadata):
    case_id: str = Field(json_schema_extra={"indexed": True})

The metadata type is declared on the card, and the value is supplied when the team is created:

runtime = manager.create_team(
    team_card=case_team_card(),
    user_id=requester_id,
    metadata=CaseMetaData(case_id=case_id),
)

This gives the framework a serializable contract it can validate and persist with the team. Marking case_id as indexed also allows an event store to find teams by that value. Indexing is opt-in; metadata may contain richer values, but only supported scalar fields can become filters.

Agents read the metadata through their orchestrator:

def find_team_case_id(agent: Akgent[Any, Any]) -> str:
    metadata = agent.orchestrator_proxy_ask.get_metadata()
    if not isinstance(metadata, CaseMetaData):
        raise WarningError(
            f"No case metadata in the team of {agent.config.name}."
        )
    return metadata.case_id

One timing detail matters: TeamManager pushes metadata after TeamFactory has built the team. An agent must therefore not expect it during on_start. The sample resolves it lazily when the first business message arrives and caches it for the lifetime of that actor.

Let the TeamManager own the lifecycle
#

TeamManager is the facade around a team lifecycle. The sample keeps it in CaseRunner, together with the actor system and event store:

self._actor_system = ActorSystem()
self._event_store = YamlEventStore(event_store_dir)
self._manager = TeamManager(
    actor_system=self._actor_system,
    event_store=self._event_store,
    subscribers=[CaseClosedSubscriber(self._closed), *subscribers],
)

Calling create_team() validates the metadata, asks TeamFactory to build all actors, persists a Process with status RUNNING, and returns a TeamRuntime. The runtime is the live handle used to send work into the team:

runtime.send(HandleCaseRequest(requester_id=requester_id))

The application no longer needs the address of every actor to start the workflow. The runtime knows the orchestrator, entry point, supervisors, and complete address map. The actors themselves can look up colleagues by their names in the orchestrator’s roster.

I keep those names together in case_team.py and use the same constants when defining cards and resolving members:

CASE_COORDINATOR = "@CaseCoordinator"
CASE_TRIAGE = "@CaseTriage"
CASE_REPOSITORY = "@CaseRepository"
USER_PROXY = "@UserProxy"

The lookup is deliberately lazy. A parent is already running before the framework finishes creating its children, so its on_start cannot safely resolve them. A cached_property performs the lookup on the first message that needs the colleague:

@cached_property
def triage_agent(self) -> ActorAddress:
    return find_team_member(self, CASE_TRIAGE)

def find_team_member(agent: Akgent[Any, Any], name: str) -> ActorAddress:
    """Look a colleague up in the team roster of the orchestrator.

    Do this while handling a message, never from `on_start`: children are
    created through the mailbox of their parent, so a parent is already running
    before its children are on the roster.

    Args:
        agent: Agent doing the lookup.
        name: Name of the colleague, `@` prefix included.

    Returns:
        Address of the colleague.

    Raises:
        WarningError: If the team has no member with this name.
    """
    address = agent.get_team_member(name)

    if address is None:
        raise WarningError(f"No {name} in the team of {agent.config.name}.")

    return address

This replaces the address setters from the core-only sample. More importantly, it also works after resume: the restored orchestrator knows the new live address associated with each stable member name.

Make infrastructure reproducible
#

The previous implementation injected a live CaseRepository instance into CaseRepositoryAgent after creating it. That kind of post-creation wiring is easy to forget when a team is reconstructed. A resumed agent needs enough serializable information to rebuild replaceable infrastructure for itself.

The repository agent now stores a dotted class path in its card configuration:

repository_agent_card = AgentCard(
    description="Access cases through a repository.",
    skills=["repository"],
    agent_class=CaseRepositoryAgent,
    config=CaseRepositoryConfig(
        name=CASE_REPOSITORY,
        role="Repository",
        backend="basic_akgents.case_repository.DummyCaseRepository",
    ),
)

In on_start, the agent imports and constructs that backend:

def on_start(self) -> None:
    self.state = CaseRepositoryState()
    self.cases = build_case_repository(self.config.backend)
    self.state.observer(self)

The class path is configuration, so it survives as part of the card. The repository instance is a live object and stays on the actor instance. This distinction makes both initial creation and resume repeatable without teaching TeamManager about application-specific dependencies.

Not every live object fits this pattern. A callback into the host process, such as a queue, UI handle, or threading.Event, cannot be reconstructed from a class path. Such an object still needs a setter and must be handed to a new runtime again after resume. The sample avoids that extra wiring by using a domain event for completion.

Announce completion with a domain event
#

The coordinator knows when the case is finished, but it should not stop its own team. Instead, it publishes a domain event through the orchestrator:

self.notify_event(
    CaseClosed(
        case_id=self.team_case_id,
        outcome=outcome,
        case_priority=case_priority,
    )
)

notify_event() wraps the value in an EventMessage. The orchestrator broadcasts it to the team’s subscribers, and the persistence subscriber stores it alongside the rest of the history. The event payload is a frozen dataclass in a stable module because its import path is part of the serialized record.

CaseClosedSubscriber bridges that event back to the thread that owns the team lifecycle:

class CaseClosedSubscriber(EventSubscriber):
    def on_message(self, msg: Message) -> None:
        if not isinstance(msg, EventMessage):
            return
        if not isinstance(msg.event, CaseClosed):
            return
        if msg.team_id is None or msg.team_id in self._restoring:
            return

        self._closed.put((msg.team_id, msg.event))

The subscriber only transports the signal to a thread-safe queue. Its callback runs on the actor thread of the orchestrator that published the event. Calling stop_team() from that callback would make the actor participate in stopping itself and can deadlock. The runner waits on the queue and performs the lifecycle operation from its own thread.

This subscriber is shared by every team created by the manager. It therefore routes on team_id instead of assuming there is only one active team, and it suppresses replayed events while a team is being restored. Otherwise an old CaseClosed event would appear to close the case a second time.

Persistence is part of team creation
#

Every team gets its own PersistenceSubscriber automatically. The application does not add it to the subscriber list. TeamManager places it in front of the shared subscribers, together with an idle-stop subscriber, whenever it creates or resumes a team.

Persistence writes three related views:

  • A Process describing the team card, owner, metadata, lifecycle status, and timestamps.
  • An ordered event stream containing the messages observed by the orchestrator.
  • The latest state snapshot for every agent.

StateChangedMessage is treated differently from other telemetry. It updates an AgentStateSnapshot instead of becoming another entry in the event stream. That is why a live event tap can show every state transition while the persisted event list contains only the most recent state of each agent beside the message history.

The sample uses YamlEventStore, which is convenient for learning because the files can be opened with a text editor. It is still an implementation of the EventStore protocol. Other backends can provide the same lifecycle and query operations without changing the team.

The event store is also the reading side. TeamManager creates, gets, resumes, stops, deletes, and updates teams, but listing teams and loading their events or agent states are event-store operations:

processes = event_store.list_teams(
    user_id=requester_id,
    status=TeamStatus.STOPPED,
    metadata={"case_id": "case_2"},
)

events = event_store.load_events(team_id)
states = event_store.load_agent_states(team_id)

That division is useful beyond the console. A web interface can describe a stopped team without starting a single actor because all information required for the view is already persisted.

Stop first, then resume when work returns
#

When the runner receives CaseClosed, it records the result and stops the team in a finally block:

try:
    runtime.send(HandleCaseRequest(requester_id=requester_id))
    event = self._await_case(runtime.id)
    return CaseRunResult(
        case_id=case_id,
        team_id=runtime.id,
        event=event,
        message_count=len(runtime.orchestrator_proxy.get_messages()),
        state_count=len(runtime.orchestrator_proxy.get_states()),
    )
finally:
    self._manager.stop_team(runtime.id)

Stopping changes the persisted process from RUNNING to STOPPED, drains the mailboxes, and tears down the actors. The drain is an important boundary: the last message of one team completes before a new user proxy can start competing for the same input channel. The application stops each team before shutting down the actor system; killing the actor system first can leave the stored process marked RUNNING, and a running team is not eligible for resume.

Resuming starts from the durable record:

process = event_store.load_team(team_id)
runtime = manager.resume_team(team_id)
runtime.send(HandleCaseRequest(requester_id=requester_id))

The framework recreates the orchestrator and agents, restores their latest state snapshots, resolves new live addresses, and replays the persisted history through the orchestrator and subscribers. It does not replay old messages through the agents’ business handlers. The human is therefore not asked the old question again and the repository is not asked to repeat an old write.

A resumed team is alive but idle until it receives fresh work. In this sample, the new HandleCaseRequest starts the case flow again. New events continue after the previous maximum sequence number, so the history remains one ordered stream across multiple runs of the same team.

The lifecycle is intentionally strict:

  • A RUNNING team can be stopped.
  • A STOPPED team can be resumed or deleted.
  • A DELETED team cannot be resumed.

This state machine prevents a second live copy of the same team and makes deletion an explicit choice rather than a side effect of stopping.

What changed for the agents?
#

Very little changed in the case-handling conversation. The coordinator still asks triage, triage still talks to the repository agent, the human still decides, and the repository agent remains the single owner of case data.

The changes are mainly about how the agents receive their context and find each other:

  • The case id moved from repeated agent configuration to team metadata.
  • The team tree moved from imperative startup code to a TeamCard.
  • Member addresses are resolved by stable names instead of injected after creation.
  • Replaceable infrastructure is reconstructed from serializable configuration.
  • Completion became a persisted domain event instead of an in-process callback.
  • Team creation and shutdown became explicit lifecycle operations on TeamManager.

That is a good sign. A lifecycle module should make the team manageable without forcing the domain conversation to become a framework conversation.

Watch the sample
#

The following video shows the sample in action. It is a silent screen recording: there is no spoken explanation, soundtrack, or other audio, so you can watch it without turning on your sound.

Watch the silent recording on YouTube

Gotchas worth keeping nearby
#

Working through the sample exposed several details that are easy to miss when a first team appears to run correctly.

Metadata is not agent configuration
#

Declare metadata_type on the card before passing a metadata value to create_team(). Only fields marked as indexed can be used as metadata filters, and those indexed fields must be supported scalar types. Agents obtain metadata from the orchestrator after construction, not from their config and not during on_start.

The card controls routing as well as structure
#

The entry point must have a headcount of one. runtime.send() sends from that entry point to the first layer in members; it does not broadcast to every nested agent. Every card needs a description, a non-empty role, and a name that is unique throughout the tree. When a member has a headcount above one, the framework appends an index to its name.

Resolve children lazily
#

Children are spawned through the mailbox of their parent. A parent’s on_start runs before those children are guaranteed to exist. Resolve team members when handling the first message that needs them. Cache only members whose lifetime matches the actor doing the caching.

Persisted message data must use the framework serializer
#

Anything travelling inside a message must be serializable by the framework. In this sample, Case inherits from SerializableBaseModel, not plain Pydantic BaseModel. A plain model containing an enum produced YAML that could be written but not safely read, which made the entire event history appear empty during resume.

Subscribers run inside actor threads
#

A shared subscriber must be thread safe and route on team_id. Keep its callback short, hand work to a queue, and never stop or resume the publishing team from inside on_message. A subscriber that reacts to domain events must also honour set_restoring; replay is history, not a new occurrence.

Stop a team before stopping its actor system
#

stop_team() is what records STOPPED and makes the team resumable. Shutting down the actor system first can leave a durable record in RUNNING. Also stop an old team before starting another team that uses the same console user proxy, or both actors can compete for input.

Distinguish reconstructable dependencies from host callbacks
#

A database client, repository, or HTTP client can be named in serializable config and built by an agent when it starts. A queue, UI handle, or callback into the host process cannot. The second kind must be injected again after every resume—or replaced with a domain event, as this sample does.

What I take into the next episode
#

akgentic-core taught me how agents collaborate. akgentic-team adds the boundary that lets that collaboration live beyond one set of actor threads. A card declares the team, metadata identifies the work, the manager controls the lifecycle, subscribers connect the team to its environment, and the event store keeps enough information to inspect or reconstruct it later.

The important shift is not that the sample gained more console commands. It is that the application can now say, “create this kind of team for this case,” and the framework takes responsibility for building, recording, stopping, and restoring it. That is the foundation needed before the team grows more capable in later episodes.

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

Related