Back to Blog

Inside the Planner: Event Choreography and Strategic Orchestration in Soorma

March 10, 2026Soorma Team

If you haven't read It's Time to DisCo, start there. It covers why single-threaded ReAct loops break down at scale and how event-driven choreography solves the problem. This post skips the argument and goes straight to the mechanics.

What Is a Planner?

In the Distributed Cognition (DisCo) model, every agent falls into one of three roles: Planner, Worker, or Tool.

A Planner does one thing: it receives a business event, decides what to do, and emits task events to Workers. It does not execute tasks itself. It does not wait for a response inline. It reasons, emits, and exits—the event bus carries forward from there.

from soorma.agents.planner import Planner, GoalContext
from soorma.context import PlatformContext

planner = Planner(name="maintenance-planner")

@planner.on_goal("maintenance.goal")
async def plan_maintenance(goal: GoalContext, context: PlatformContext) -> None:
    vehicle_id = goal.data.get("vehicle_id")

    # Store intent in working memory so Workers can retrieve it
    await context.memory.store(
        f"vehicle:{vehicle_id}",
        {"vehicle_id": vehicle_id, "required_parts": ["brake_pads", "oil_filter"]},
        plan_id=goal.correlation_id,
    )

    # Emit a task event — the Worker will react independently
    await context.bus.request(
        event_type="parts.check.requested",
        data={"vehicle_id": vehicle_id},
        response_event="parts.check.completed",
        correlation_id=goal.correlation_id,
    )

planner.run()

The response_event Contract

Notice the response_event field. This is not optional decoration — it is the explicit correlation contract between Planner and Worker.

When the Planner publishes parts.check.requested with response_event="parts.check.completed", it is declaring: "I expect a reply on this event type." The platform uses this to:

  1. Route responses: The completion event is delivered back to the originating Planner via its subscription.
  2. Enforce choreography: No inferred event names. Every event chain is explicit and auditable.
  3. Enable observability: The Tracker service records the full lifecycle from task emission to completion against this contract. (More on the Tracker in the next post.)

Inferred event names — where the platform guesses what the response will be called — are explicitly forbidden in Soorma. See ARCHITECTURE_PATTERNS.md Section 3 for the full choreography specification.

Plan State Machines

When a Planner orchestrates more than one Worker, the coordination challenge grows. Soorma tracks multi-step plans through context.tracker, which records a plan as a state machine with the following lifecycle:

PENDING → RUNNING → COMPLETED | FAILED | CANCELLED

Each task within a plan has its own nested state:

PENDING → RUNNING → DELEGATED | WAITING | COMPLETED | FAILED | CANCELLED

Plan tracking does not require an explicit API call. The Planner drives state through PlanContext — a durable object that persists plan state in the Memory service and advances through transitions as response events arrive. The Tracker observes those transitions automatically via the response_event correlation chain.

As Workers complete their tasks, they emit response events. The SDK transitions plan and task states, advancing to the next orchestration step. When all tasks reach terminal states, the plan finalises.

This gives you durable orchestration without a workflow engine. The state lives in the Memory service, not in the Planner process. If the Planner restarts, the plan is recoverable via context.memory.get_plan_context(). See ARCHITECTURE_PATTERNS.md Section 5 for the full PlanContext state machine specification.

Working Memory and Planner Isolation

A Planner should never pass all required data directly in task events. Instead, it stores rich context in working memory and passes only a key:

# Planner stores context (plan_id scopes the working memory entry)
await context.memory.store(
    f"plan:{plan_id}:vehicle",
    vehicle_data,
    plan_id=plan_id,
)

# Emits only the key
await context.bus.request(
    event_type="parts.check.requested",
    data={"vehicle_id": vehicle_id, "plan_id": plan_id},
    response_event="parts.check.completed",
    correlation_id=goal.correlation_id,
)

The Worker retrieves the full context itself:

from soorma.task_context import TaskContext

@worker.on_task("parts.check.requested")
async def check_parts(task: TaskContext, context: PlatformContext) -> None:
    plan_id = task.data.get("plan_id")
    vehicle = await context.memory.retrieve(f"plan:{plan_id}:vehicle", plan_id=plan_id)
    # ... process

This pattern keeps event payloads small, enforces the abstraction boundary, and means the Planner never needs to know the internal data requirements of each Worker.

What v0.9.1 Ships

The v0.9.1 release includes:

  • The full Planner class with on_goal handler registration
  • context.bus.publish() with response_event parameter enforcement
  • PlanContext for durable plan orchestration with automatic Tracker observation
  • Working memory via context.memory.store() and context.memory.retrieve()

The examples directory includes an example that demonstrates Planner agent with state machine-based orchestration. Start with 01-hello-world to get the SDK running locally before jumping into orchestration patterns.


Next up: Tracker Observability — Understanding Plan and Task State in Soorma.