Tutorial: 4 Equal Agents in a Peer Mesh (No Single "Main" Agent)

View Source

This tutorial shows how to design four equal agents in Synaptic that can call each other as needed through the Agent Directory + Router control plane.

Unlike a strict coordinator/worker model, this pattern treats each agent as a peer:

  • any agent can initiate work
  • any agent can delegate to another agent
  • any agent can resume or inspect tasks (if authorized)
  • there is no permanent "boss" agent in the architecture

Topic: Product Launch Intelligence Mesh

We will use a practical topic where peer communication makes sense:

"Product launch intelligence" for a company preparing a new release.

The four agents:

  • market.scan - scans market signals and competitor movements
  • social.listening - summarizes community/social chatter and sentiment
  • pricing.strategy - proposes pricing hypotheses and scenarios
  • risk.review - challenges assumptions and generates risk checks

Each of these agents can call the others when needed.

Examples:

  • pricing.strategy may call market.scan to fetch competitor moves
  • risk.review may call social.listening to check backlash signals
  • social.listening may call market.scan to verify whether chatter maps to real market events
  • market.scan may call risk.review before returning a recommendation summary

Why This Pattern Matters

This design demonstrates the actual flexibility of the new Synaptic control plane:

  • not voice-specific
  • not coordinator-only
  • not a single static workflow graph
  • dynamic peer-to-peer delegation via service registry + router

It is closer to a real multi-agent system / agent mesh.

Architecture (Peer Mesh)

All four agents are registered as services and can discover/invoke each other.

market.scan <--> social.listening
     |               |
     v               v
pricing.strategy <--> risk.review

In practice, communication flows through Synaptic's router (not direct process coupling):

1. Register the 4 Peer Agents

alias MyApp.Agents.{MarketScanWorkflow, SocialListeningWorkflow, PricingStrategyWorkflow, RiskReviewWorkflow}

services = [
  {"market.scan", MarketScanWorkflow, ["market.scan"]},
  {"social.listening", SocialListeningWorkflow, ["social.listening"]},
  {"pricing.strategy", PricingStrategyWorkflow, ["pricing.strategy"]},
  {"risk.review", RiskReviewWorkflow, ["risk.review"]}
]

Enum.each(services, fn {service_id, mod, capabilities} ->
  {:ok, _} =
    Synaptic.register_agent_service(
      service_id,
      %{
        kind: :workflow,
        capabilities: capabilities,
        visibility: :tenant,
        lifecycle_mode: :spawn_on_demand,
        provider: {:workflow_module, mod}
      }
    )
end)

To make peer-to-peer collaboration stable, define a shared payload convention.

Each agent call should include:

  • request_id - stable correlation id for a user request
  • goal - what the caller is trying to achieve
  • context_pack - structured facts gathered so far
  • purpose - task labeling for recovery/querying
  • delegation_reason - why this agent is calling another one

Example payload shape:

%{
  request_id: "launch-intel-2026-02-24-001",
  goal: "Assess launch viability for Product X",
  delegation_reason: "Need competitor pricing baselines",
  context_pack: %{
    product: "Product X",
    region: "US",
    launch_window: "Q2",
    known_competitors: ["A", "B", "C"]
  },
  purpose: "launch_intelligence_mesh"
}

3. Peer-to-Peer Call Example (Pricing -> Market)

pricing.strategy may need current competitor pricing movement before finalizing a recommendation.

caller_ctx = %{
  tenant_id: "default",
  user_id: "user_42",
  caller_agent_id: "pricing.strategy",
  session_id: "launch_mesh_session_1"
}

{:ok, market_result} =
  Synaptic.agent_call(
    "market.scan",
    %{
      request_id: "launch-intel-2026-02-24-001",
      goal: "Assess launch viability for Product X",
      delegation_reason: "Need competitor pricing baselines",
      context_pack: %{
        product: "Product X",
        region: "US",
        launch_window: "Q2",
        competitors: ["A", "B", "C"]
      },
      purpose: "launch_intelligence_mesh"
    },
    caller_ctx: caller_ctx,
    aliases: ["last_market_scan"],
    timeout: 10_000
  )

If market.scan suspends, pricing.strategy can resume it through the returned handle.

4. Another Peer Call (Risk -> Social)

risk.review may ask social.listening for sentiment/complaint patterns.

risk_ctx = %{
  tenant_id: "default",
  user_id: "user_42",
  caller_agent_id: "risk.review",
  session_id: "launch_mesh_session_1"
}

{:ok, social_result} =
  Synaptic.agent_call(
    "social.listening",
    %{
      request_id: "launch-intel-2026-02-24-001",
      goal: "Assess launch viability for Product X",
      delegation_reason: "Need pre-launch sentiment and complaint themes",
      context_pack: %{
        product: "Product X",
        audience: ["devs", "teams", "ops"],
        channels: ["X", "Reddit", "Discord"]
      },
      purpose: "launch_intelligence_mesh"
    },
    caller_ctx: risk_ctx,
    aliases: ["last_social_listening"],
    timeout: 10_000
  )

5. No Single Main Agent: How State Still Stays Coherent

When agents are peers, the main challenge is not calling each other. The challenge is maintaining coherence.

Use these conventions:

  • Shared request_id across all related tasks
  • Shared purpose (e.g. "launch_intelligence_mesh")
  • Agent-specific aliases (e.g. last_market_scan, last_risk_review)
  • Structured context_pack payloads instead of raw text only

This lets any peer recover the chain.

6. Recovery Example (Any Peer Can Recover Another Task)

Suppose pricing.strategy crashes and restarts. It can recover the latest market.scan task for the same user/request.

{:ok, task_ref} =
  Synaptic.AgentDirectory.resolve_task_reference(%{
    user_id: "user_42",
    capability: "market.scan",
    alias: "last_market_scan",
    require_active: true
  })

{:ok, status_result} =
  Synaptic.agent_call(
    %{task_ref_id: task_ref.task_ref_id},
    %{action: :inspect},
    caller_ctx: %{
      tenant_id: "default",
      user_id: "user_42",
      caller_agent_id: "pricing.strategy"
    }
  )

7. Example Peer Workflow Behaviors (Suggested)

These are not exact implementations, but practical shapes.

market.scan

Responsibilities:

  • identify competitor actions
  • summarize launch timing/messaging moves
  • return evidence and confidence

Can delegate to:

  • risk.review ("What assumptions are weak?")
  • social.listening ("Is this signal reflected in user chatter?")

social.listening

Responsibilities:

  • summarize sentiment themes
  • identify recurring complaints/questions
  • estimate volatility in discussion

Can delegate to:

  • market.scan ("Did a competitor trigger this conversation?")
  • risk.review ("Are these signals operationally relevant?")

pricing.strategy

Responsibilities:

  • produce pricing options (not final decision)
  • compare scenarios
  • explain tradeoffs

Can delegate to:

  • market.scan (competitor baselines)
  • social.listening (price sensitivity chatter)
  • risk.review (risk constraints before presenting options)

risk.review

Responsibilities:

  • challenge assumptions
  • identify blind spots and downside scenarios
  • propose monitoring checks

Can delegate to:

  • market.scan (verify competitor reality)
  • social.listening (reputation/sentiment risks)
  • pricing.strategy (stress-test plan under constraints)

When everyone is a peer, define simple rules to avoid chaos.

Rule A: Include caller identity in caller_ctx

Always set caller_agent_id correctly.

This improves:

  • auditability
  • policy decisions
  • debugging logs

Rule B: Use capability-specific aliases

Avoid generic aliases like last_task.

Prefer:

  • last_market_scan
  • last_social_listening
  • last_pricing_strategy
  • last_risk_review

Rule C: Keep delegations narrow

A peer call should request one thing, not an entire orchestration.

Good:

  • "Get competitor price changes for last 30 days"

Bad:

  • "Do everything and tell me what to do"

Rule D: Return structured outputs

Use maps with stable keys so downstream peers can consume them reliably.

9. Optional Pattern: Temporary Convening Agent (Still Peer-Friendly)

Even in a peer mesh, you may sometimes create a temporary orchestration task for a single request.

That is still consistent with this design as long as:

  • it is not a permanent privileged coordinator
  • other peers can initiate their own delegations independently
  • all interactions still go through the same directory/router/policy layer

Think of it as a temporary convening role, not a hard-coded hierarchy.

10. Observability Checklist for Peer Mesh Testing

When testing peer-to-peer calls, log these values on each hop:

  • caller_agent_id
  • target service_id
  • handle.instance_id
  • handle.task_ref_id
  • handle.run_id
  • snapshot.status
  • request_id
  • purpose

This makes it much easier to validate peer-call chains and recover tasks after failures.

11. Common Failure Modes (and how this architecture helps)

Failure: Caller agent loses local memory

Fix:

  • recover by task reference (capability + alias + user_id + recency)

Failure: Too many similar tasks for one user

Fix:

  • use better aliases and purpose
  • include request_id in payload/context and surface it in task metadata (recommended future improvement)

Failure: Peer loops / circular delegation

Fix (design rule):

  • add delegation depth counters in payload
  • set max delegation depth
  • log delegation_reason

12. Practical Implementation Recipe

If you want to build this in your app, do it in this order:

  1. Create 4 workflows (one per peer agent)
  2. Register them as services in app startup
  3. Standardize a shared payload contract (request_id, goal, context_pack, purpose)
  4. Add aliases per capability
  5. Add policy rules (who may call whom)
  6. Add recovery logic using resolve_task_reference/2
  7. Add logs/telemetry for each peer hop

13. Minimal Manual Test Script (IEx)

Use this to validate the architecture concept quickly.

# 1. Register the 4 services (see section 1)

# 2. Start one peer call (pricing -> market)
caller_ctx = %{tenant_id: "default", user_id: "user_42", caller_agent_id: "pricing.strategy"}

{:ok, market_result} =
  Synaptic.agent_call(
    "market.scan",
    %{
      request_id: "launch-intel-001",
      goal: "Assess launch viability",
      delegation_reason: "Need competitor movement summary",
      context_pack: %{product: "Product X", region: "US"},
      purpose: "launch_intelligence_mesh"
    },
    caller_ctx: caller_ctx,
    aliases: ["last_market_scan"]
  )

# 3. Recover it from another peer identity (risk.review)
{:ok, task_ref} =
  Synaptic.AgentDirectory.resolve_task_reference(%{
    user_id: "user_42",
    capability: "market.scan",
    alias: "last_market_scan",
    require_active: true
  })

{:ok, status_result} =
  Synaptic.agent_call(
    %{task_ref_id: task_ref.task_ref_id},
    %{action: :inspect},
    caller_ctx: %{tenant_id: "default", user_id: "user_42", caller_agent_id: "risk.review"}
  )

If this works, you have verified a peer-mesh style agent system where all four agents are equals and can communicate through Synaptic's shared control plane.