The first working agent in this series is about 260 lines of Python: a Strands Agent, two read-only tools that assume a cross-account IAM role before calling boto3, and a system prompt whose entire job is to stop the model from sounding confident about something it has not actually checked. No AgentCore Gateway yet, no supervisor, no multi-agent handoff: those come in later parts. This part is about getting one agent doing one job correctly, deployed to AgentCore Runtime, before adding anything that makes debugging it harder.

Part 1 set the scenario (a 50-engineer SaaS shop, 40 pages a week, read-only enforced by IAM as the non-negotiable constraint) and picked AgentCore plus Strands over classic Bedrock Agents or a self-built loop. Part 2 built the account boundary those agents live inside: the ops-readonly and ops-mutate spoke roles, and a per-agent application inference profile wrapping Bedrock's cross-region routing. This part uses both directly. Nothing here is provisioned from scratch; it consumes what Part 2 already built. The companion code lives at github.com/flightlesstux/agents-on-call, in agents/triage/agent.py and terraform/10-agent-runtime/, and every snippet below is excerpted from those files, not simplified for the post.

Anatomy of a Strands agent

Strands Agents is a model-driven Python SDK, open sourced by AWS in May 2025 under Apache 2.0 and used internally for Amazon Q Developer, AWS Glue, and VPC Reachability Analyzer before it ever left the building. The pitch is not a new orchestration language: it is a thin loop around a model that already knows how to call tools, wired to Bedrock, Anthropic direct, Ollama, or a LiteLLM-proxied provider without changing how the agent is written. The whole shape of one agent fits in five steps.

1. Configuration comes from the environment, not from literals in the file. Everything the runtime needs to know is set as an environment variable by the Terraform module below, so nothing account-specific or secret is hardcoded in source:

AWS_REGION = os.environ.get("AWS_REGION", "eu-west-1")
INFERENCE_PROFILE_ARN = os.environ["AGENT_INFERENCE_PROFILE_ARN"]
OPS_READONLY_ROLE_NAME = os.environ.get("OPS_READONLY_ROLE_NAME", "ops-readonly")
SPOKE_EXTERNAL_ID = os.environ["SPOKE_EXTERNAL_ID"]

2. Tools are plain Python functions with a decorator. Strands reads a function's type hints and docstring to build the tool spec the model actually sees, so the docstring is not a comment for humans, it is the interface contract:

@tool
def cloudwatch_read(
    spoke_account_id: str,
    namespace: str,
    metric_name: str,
    dimensions: dict[str, str],
    lookback_minutes: int = 60,
    stat: str = "Average",
    period_seconds: int = 60,
) -> str:
    """Read a CloudWatch metric's recent datapoints from a spoke account.
    ...
    """

3. The model is wired through the Part 2 inference profile, not a raw model ID. Temperature stays low on purpose: this agent proposes a diagnosis from evidence, it does not need creative variety in its wording.

model = BedrockModel(
    model_id=INFERENCE_PROFILE_ARN,
    region_name=AWS_REGION,
    temperature=0.1,
    max_tokens=2048,
)

4. The agent itself is one constructor call: a model, a system prompt, and a list of tools. There is no orchestration graph to author, because Strands infers the tool-calling loop from the model's own tool-use output:

agent = Agent(
    model=model,
    system_prompt=SYSTEM_PROMPT,
    tools=[cloudwatch_read, logs_read],
    name="incident-triage",
    description=(
        "First-pass responder for CloudWatch alarms: reads metrics and logs, proposes a "
        "root cause candidate, defers all remediation to a human or the runbook agent."
    ),
)

5. An AgentCore Runtime entrypoint wraps it. BedrockAgentCoreApp comes from the separate bedrock-agentcore package, not Strands itself: Strands is the agent framework, AgentCore is the hosting layer, and the @app.entrypoint decorator is the seam between the two.

app = BedrockAgentCoreApp()

@app.entrypoint
def invoke(payload: dict) -> dict:
    """AgentCore Runtime entrypoint.
    ...
    """
    prompt = payload.get("prompt")
    if not prompt:
        return {"error": "payload.prompt is required"}

    result = agent(prompt)
    return {"result": str(result)}

if __name__ == "__main__":
    app.run()

Calling agent(prompt) is the entire agent loop from the outside: Strands sends the prompt and tool specs to Bedrock, gets back either a final answer or a tool-use request, executes the requested tool, feeds the result back, and repeats until the model stops asking for tools. None of that loop is code this project owns; it is code this project would have had to write by hand under the self-built option Part 1 rejected.

System prompt design: evidence before conclusions

A triage agent that sounds confident is more dangerous than one that says "I don't know yet," because a human reading its output at three in the morning acts on the tone as much as the content. The system prompt exists almost entirely to prevent that failure mode, not to make the agent smarter:

1. Evidence before conclusions. Call cloudwatch_read and logs_read before stating any
   root cause candidate. A hypothesis formed before you have queried at least one metric
   and one log group is a guess, not a diagnosis, and you must not present it as one.
2. Cite the metric name and namespace for every claim: say "AWS/ApplicationELB
   TargetResponseTime rose from 120ms to 4.8s over the last 15 minutes", not "latency went
   up". A reader with no access to your tool calls should be able to verify every number you
   state by re-running the same query.
3. State uncertainty explicitly. ...
4. No speculation about causes your tools cannot see. ...
5. Empty results are findings. A metric with no datapoints in the query window, or a log
   query that returns zero rows, is evidence (the failure is silent, or upstream of this log
   group), not a reason to retry with a wider window until something shows up.
6. Close with a structured summary: the alarm, the root cause candidate(s) with confidence,
   the exact metrics and log queries you ran, and what you would check next if you had one
   more tool call.

Rule two is doing the most work. "Cite the metric name" sounds like a stylistic preference, but it is what turns the agent's output from a claim a human has to trust into a claim a human can verify in fifteen seconds by pasting the same CloudWatch query. Rule five exists because the natural failure of an agent with a retry budget is to keep widening the time window or changing the query until it finds something, at which point "something" gets reported as the cause even when it is unrelated. An empty result is data. Treating it as a dead end to report, instead of a prompt to keep fishing, is what keeps the agent from manufacturing a diagnosis out of noise.

Two tools, plain Python, no Gateway yet

The target architecture from Part 1 puts every tool behind AgentCore Gateway: MCP-compatible, centrally IAM-scoped, one allowlist for four tools across the whole platform. That is Part 4. Here, with exactly one agent and two tools, Gateway would be indirection with nothing yet to route between. Both tools are plain @tool-decorated Python functions that the agent calls directly, and both do the same thing before touching AWS: assume ops-readonly in the target spoke account.

def _assume_ops_readonly(spoke_account_id: str) -> boto3.Session:
    sts = boto3.client("sts", region_name=AWS_REGION, config=_boto_config)
    role_arn = f"arn:aws:iam::{spoke_account_id}:role/{OPS_READONLY_ROLE_NAME}"
    creds = sts.assume_role(
        RoleArn=role_arn,
        RoleSessionName="triage-agent-read",
        ExternalId=SPOKE_EXTERNAL_ID,
        DurationSeconds=900,
    )["Credentials"]
    return boto3.Session(
        aws_access_key_id=creds["AccessKeyId"],
        aws_secret_access_key=creds["SecretAccessKey"],
        aws_session_token=creds["SessionToken"],
        region_name=AWS_REGION,
    )

Fifteen minutes, not the one-hour ceiling ops-readonly's trust policy allows: a tool call that still needs credentials after fifteen minutes is stuck on something else, and a shorter-lived credential shrinks the blast radius if it ever leaked out of the process. cloudwatch_read wraps get_metric_statistics and returns datapoints as JSON, oldest first. logs_read starts a CloudWatch Logs Insights query and polls for completion, capped at eighteen seconds across twenty attempts, and raises rather than returning an empty result on a stuck query: a log query that silently times out and reports zero matching rows reads exactly like "no errors found," which is the one failure mode this tool cannot be allowed to have. Both tools accept a spoke_account_id argument, which means the model decides which account to query based on what the alarm event tells it, not a value baked into the deployment.

What AgentCore Runtime buys over Lambda or ECS

Nothing here strictly requires AgentCore Runtime. A Strands agent is regular Python; it could run in a Lambda function or an ECS task with a boto3 client and a loop. AgentCore Runtime, at general availability since October 13, 2025, buys three things that would otherwise be custom infrastructure:

  • An eight-hour execution window per session. Lambda's fifteen-minute hard ceiling forces either a very short-lived agent or a hand-built continuation mechanism across invocations for anything that runs longer, which a multi-tool diagnosis loop with retries can. AgentCore Runtime's session model does not need that workaround.
  • Complete session isolation between invocations, without building a per-session sandbox out of separate Lambda execution contexts or ECS tasks by hand.
  • Per-second billing with no charge for idle CPU. $0.0895 per vCPU-hour and $0.00945 per GB-hour, billed to the second, with a 128MB memory floor, and specifically no charge for the time an agent spends blocked waiting on an LLM response or a tool call, which is typically 30 to 70 percent of a session's wall-clock time. An ECS task or a provisioned Lambda would bill for that idle time regardless of whether the CPU is doing anything.

The trade-off is a real constraint, not a footnote: AgentCore Runtime only supports the ARM64 instruction set, so any dependency with compiled extensions has to resolve to an ARM64 or manylinux wheel, or the deployment fails ELF-header validation at upload time. This agent's two dependencies, strands-agents and bedrock-agentcore, ship no compiled extensions today, so it does not bite here. It will bite the first agent in this series that needs something like a native database driver, and the fix at that point is building the deployment artifact with pip install --platform manylinux2014_aarch64 --only-binary=:all: rather than a plain pip install.

Terraform for the runtime

aws_bedrockagentcore_agent_runtime supports two mutually exclusive artifact types under agent_runtime_artifact: code_configuration, an S3 zip with a runtime version and an entry point, or container_configuration, an ECR image URI. This module uses code_configuration. AgentCore's own CLI defaults new agents to the same direct-code-deploy path (internally called CodeZip) rather than a container build, and with exactly two dependencies and no system packages to install, a Dockerfile would be ceremony this agent does not need yet. A future agent whose dependencies need compiling, or whose runtime is not Python, is the trigger to switch to container_configuration, not a default preference for one artifact type over the other.

resource "aws_bedrockagentcore_agent_runtime" "triage" {
  agent_runtime_name = "${replace(var.platform_name, "-", "_")}_triage"
  description        = "Incident-triage agent: reads CloudWatch metrics and Logs Insights across spokes, diagnoses, never mutates."
  role_arn           = aws_iam_role.triage_runtime.arn

  agent_runtime_artifact {
    code_configuration {
      entry_point = ["agent.py"]
      runtime     = "PYTHON_3_12"

      code {
        s3 {
          bucket     = aws_s3_bucket.agent_artifacts.id
          prefix     = aws_s3_object.triage_agent_code.key
          version_id = aws_s3_object.triage_agent_code.version_id
        }
      }
    }
  }

  environment_variables = {
    AWS_REGION                  = var.aws_region
    AGENT_INFERENCE_PROFILE_ARN = var.agent_inference_profile_arn
    OPS_READONLY_ROLE_NAME      = var.ops_readonly_role_name
    SPOKE_EXTERNAL_ID           = var.spoke_external_id
  }

  network_configuration {
    network_mode = "PUBLIC"
  }

  protocol_configuration {
    server_protocol = "HTTP"
  }

  tags = merge(var.tags, {
    Agent = "triage"
  })
}

network_mode = "PUBLIC" rather than a VPC attachment is deliberate: this agent's only outbound calls are Bedrock model invocation and cross-account sts:AssumeRole, both public AWS service endpoints reached over IAM-authenticated HTTPS. There is no private network resource, a database or an internal load balancer, that this agent talks to directly, so a VPC attachment would add NAT and endpoint cost for nothing. The execution role's permission policy is scoped in three directions that mirror the three things this agent is actually allowed to touch: bedrock:InvokeModel on the inference profile ARN and on the underlying foundation-model ARNs it can route to (both, because cross-region inference authorizes at the profile level and the destination-model level, and missing the second grant fails only the invocations that happen to route to a region without it, the exact intermittent-looking failure Part 2 flagged for model access itself); sts:AssumeRole scoped to the exact ops-readonly ARN in every spoke account this agent is allowed to query, built from a list, not a wildcard; and s3:GetObject on its own code artifact prefix, nothing wider.

A first diagnosis, alarm to summary

A CloudWatch alarm on AWS/ApplicationELB TargetResponseTime fires for the checkout service in a spoke account. The supervisor agent does not exist yet (Part 5), so for this part a human pastes the alarm context into the agent directly. What comes back, condensed:

> cloudwatch_read(spoke_account_id="222222222222",
    namespace="AWS/ApplicationELB", metric_name="TargetResponseTime",
    dimensions={"LoadBalancer": "app/prod-checkout/50dc6c495c0c9188"},
    lookback_minutes=30)
< 30 datapoints, p-average rising from 0.14s to 4.6s over the last 12 minutes

> cloudwatch_read(spoke_account_id="222222222222",
    namespace="AWS/ApplicationELB", metric_name="HTTPCode_Target_5XX_Count",
    dimensions={"LoadBalancer": "app/prod-checkout/50dc6c495c0c9188"},
    lookback_minutes=30)
< 5XX count flat near zero for 18 minutes, then rising with TargetResponseTime

> logs_read(spoke_account_id="222222222222",
    log_group_name="/aws/ecs/prod-checkout",
    query_string='fields @timestamp, @message | filter @message like /timeout|pool/ | sort @timestamp desc',
    lookback_minutes=15)
< 41 rows, dominant message: "HikariPool-1 - Connection is not available,
  request timed out after 30000ms"

The agent's summary: AWS/ApplicationELB TargetResponseTime on app/prod-checkout rose from 140ms to 4.6s over 12 minutes, with HTTPCode_Target_5XX_Count beginning to climb roughly six minutes after the latency rise started. CloudWatch Logs Insights against /aws/ecs/prod-checkout in the same window returns 41 rows matching a connection pool timeout, most likely root cause: the service's database connection pool is exhausted, and requests are queueing until they hit the 30-second pool timeout, which is what is showing up as elevated TargetResponseTime before it becomes an outright 5XX. Confidence: moderate, not high, because this agent has no tool that can read the database's own connection count or the pool's configured maximum, so it cannot confirm the pool is actually full versus, for example, a downstream dependency holding connections open longer than usual. Recommended next check: the runbook agent's database connection metrics, once that agent exists, or a human checking the pool's current active-connection count directly.

That last paragraph is the system prompt's rules working as intended, not a hedge added for the post. The agent has real evidence for a real hypothesis, cites the exact metrics and query behind it, and stops exactly at the edge of what its two tools can actually see, naming the gap instead of guessing across it.

Failure modes to watch

Three things worth knowing before this agent runs against a real alarm. First, an AssumeRole failure against ops-readonly, wrong account ID, expired external ID, a spoke not yet onboarded, has to surface as "I could not read this account" in the final summary, not as a silently skipped tool call the model works around by reasoning from the one data source that did succeed; a partial diagnosis presented with the same confidence as a complete one is worse than no diagnosis. Second, the eighteen-second Logs Insights timeout is a real ceiling: a query against a high-volume log group with a broad filter can legitimately take longer, and the fix is a tighter query (narrower time window, more specific filter) from the model, not a longer timeout that turns a stuck tool call into a stuck agent invocation. Third, low temperature reduces wording variance but does not guarantee the model calls both tools before answering; the system prompt's rule one is the actual enforcement mechanism, and it is worth testing against alarms deliberately designed to tempt a shortcut, an alarm whose alarm name alone strongly implies a cause, to confirm the agent still queries before concluding.

Read this next

The full agent.py and the complete terraform/10-agent-runtime/ module, including the IAM policy documents trimmed from the snippets above, live in the companion repository at github.com/flightlesstux/agents-on-call. For the database and infrastructure side of running platforms like this one at scale, the field notes are at ercan.cloud, and the hub is at ercanermis.com.