Runtime architecture

Component map

flowchart TB
  subgraph container ["AgentCore container (linux/arm64, PYTHONPATH=/app)"]
    Main["main.py\n@app.entrypoint agent_handler()"]
    Cfg["config/loader.py\nload_config() → AppConfig"]
    Reg["workflows/registry.py\nWorkflowRegistry"]
    Base["workflows/base_workflow.py\nBaseWorkflow.execute_steps()"]

    subgraph wf ["workflows/builtin/"]
      PW["PromptWorkflow\n'test'"]
      DW["DiallerAgentWorkflow\n'dialler-agent'"]
    end

    subgraph ag ["providers/agents/"]
      DA["dialler_agent\nClaudeAgentService singleton"]
    end

    Types["types/__init__.py\nWorkflowStep / WorkflowResult"]
  end

  subgraph lib ["morpheus-llm (installed dependency)"]
    CS["ClaudeAgentService"]
    BS["BaseAgentService\ntools + memory"]
    Tools["create_hubspot_mcp()\ncreate_tavily_mcp()\nadd_numbers"]
  end

  SM(["Secrets Manager"])
  BR(["Amazon Bedrock\nClaude Sonnet 4"])
  MEM[("AgentCore Memory")]
  HS(["HubSpot MCP"])

  Main --> Cfg --> SM
  Main --> Reg --> PW & DW
  DW --> Base
  PW & DW --> DA --> CS --> BS
  CS --> BR
  BS --> MEM
  DA --> Tools --> HS
  Base --> Types

Module responsibilities

main.py

Startup and routing. Calls load_config(), then build_registry(), then starts BedrockAgentCoreApp. The @app.entrypoint async generator parses the payload, builds RunOptions from the AgentCore context, delegates to registry.run(), and yields one SSE event.

config/loader.py

Owns load_config() — the single source of credentials. Reads SECRET_ARN, fetches from Secrets Manager, validates with _SecretsSchema, injects into os.environ, builds an immutable AppConfig, and caches it in a module global. See Configuration.

workflows/registry.py

WorkflowRegistry — a dict[str, WorkflowDefinition] with register(), list(), and run(). Also defines the WorkflowDefinition runtime-checkable protocol.

workflows/base_workflow.py

The multi-step execution engine: add_step(), execute_steps(), and {{variable}} prompt templating. One shared Strands Agent per invocation, so steps accumulate conversation history automatically.

workflows/builtin/

Concrete workflows. PromptWorkflow implements the protocol directly; DiallerAgentWorkflow extends BaseWorkflow.

providers/

Thin re-exports of morpheus_llm.providers (BaseAgentService, ClaudeAgentService), plus providers/agents/ holding module-level agent singletons.

types/init.py

WorkflowStep, WorkflowStepResult, WorkflowResult, InvocationPayload, and re-exports of AgentConfig, AgentResult, RunOptions from morpheus_llm.

tools/builtin/

Local @tool functions. Production tools live in morpheus_llm so the Lambdas can use them too.

constants.py

Environment-driven constants: LOG_GROUP_NAME, AGENT_MAX_TOKEN, MEMORY_ID, DEFAULT_SYSTEM_PROMPT, DEFAULT_AGENT_NAME.

Startup sequence

sequenceDiagram
  participant P as Python import
  participant C as load_config()
  participant SM as Secrets Manager
  participant A as dialler_agent module
  participant R as build_registry()
  participant S as BedrockAgentCoreApp

  P->>C: load_config()
  C->>SM: GetSecretValue(SECRET_ARN)
  SM-->>C: JSON credentials
  C->>C: _SecretsSchema.model_validate()
  C->>C: inject into os.environ, cache AppConfig
  P->>R: build_registry()
  R->>A: import agentcore.providers.agents.dialler_agent
  A->>C: load_config() (cache hit)
  A->>A: construct ClaudeAgentService singleton
  R->>R: register PromptWorkflow + DiallerAgentWorkflow
  P->>S: BedrockAgentCoreApp() then app.run()

Order matters. dialler_agent calls load_config() at module import time to get HubSpot credentials for its tool factory. Because main.py calls load_config() before importing the registry, the agent module hits a warm cache. A missing SECRET_ARN therefore fails at startup with a clear RuntimeError rather than at the first invocation.

Request lifecycle

sequenceDiagram
  participant C as Caller
  participant EP as agent_handler()
  participant R as WorkflowRegistry
  participant W as DiallerAgentWorkflow
  participant B as BaseWorkflow
  participant S as ClaudeAgentService
  participant A as strands.Agent
  participant M as AgentCore Memory
  participant BR as Bedrock
  participant H as HubSpot MCP

  C->>EP: {"workflow":"dialler-agent","payload":{…},"actorId":"…"}
  EP->>EP: RunOptions(session_id=ctx.session_id, actor_id=payload.actorId)
  EP->>R: run("dialler-agent", payload, options)
  R->>W: run(payload, options)
  W->>B: execute_steps({"phoneNumber": …}, options)
  B->>S: new_session(options)
  S->>A: Agent(model, system_prompt, tools(), session_manager)
  B->>A: step 1 "analyse-history" prompt + JSON schema
  A->>M: retrieve memory records
  A->>BR: inference
  BR-->>A: tool_use
  A->>H: HubSpot MCP call
  H-->>A: contact + history
  A-->>B: text → validated AnalyseHistoryOutput
  B->>A: step 2 "prepare-brief" (same Agent, history intact)
  A->>BR: inference
  A->>M: persist turns
  A-->>B: text → validated PrepareBriefOutput
  B-->>W: WorkflowResult
  W-->>EP: WorkflowResult
  EP-->>C: SSE event {"session_id": …, "data": WorkflowResult}

Two design decisions worth knowing

Why an async generator entrypoint

The /invocations route uses SSE streaming. Yielding from the entrypoint routes into the SSE-aware code path, which manages heartbeats correctly for long-running workflows. A plain return would take the non-streaming path and risk a client timeout on a multi-step workflow.

Why a fresh Agent per invocation

ClaudeAgentService instances are module-level singletons living for the container’s lifetime. Constructing the Strands Agent in init would accumulate agent.messages across every request the container ever served, leaking one caller’s conversation into another’s. _make_agent() is therefore called per invocation, from run() or new_session().

The same reasoning applies to MCP tools: MCPClient transports hold an open HTTP session, so tools are supplied as a factory lambda and re-created per invocation.

Why python -m agentcore.main

Running python agentcore/main.py puts agentcore/ at sys.path[0], which makes agentcore/types/ shadow the standard-library types module and breaks imports inside the SDKs. The -m form puts the working directory (/app) first, so agentcore.types resolves as a package attribute and the stdlib module stays reachable. The dev Make target uses python -m agentcore.main.

The Dockerfile declares CMD ["python", "-m", "main"], not python -m agentcore.main, and its build context is agentcore/ with COPY . ./, so the package’s contents land flat in /app. That is inconsistent with main.py’s absolute `agentcore.* imports. Verify the container actually starts before relying on a fresh image build. See the container section.