AWS Agent Registry (GA August 2026) gives a team a private catalog for AI agents, MCP servers, and tools — semantic search, approval workflows, CloudTrail audit trails. What it doesn't give you out of the box is a way to find the agents that were never registered in the first place.
This is a pattern for closing that gap: scan the AWS account for AgentCore runtimes, diff them against what's actually in the registry, and route anything unregistered through a real approval flow before it becomes a permanent record. I built it as Shadow Agent Hunter; this post is about the Agent Registry API details that made it work (and the ones that didn't, at first).
The three API calls that matter
Agent Registry splits into a control plane (agent-registry-control) for managing registries and records, and a data plane (agent-registry) for searching approved ones.
Finding what's already registered. list_registry_records returns every record regardless of status, so pending/draft records don't get re-flagged on a rescan either:
import boto3
control = boto3.client("agent-registry-control", region_name="us-east-1")
registered_names = set()
next_token = None
while True:
kwargs = {"registryId": registry_id}
if next_token:
kwargs["nextToken"] = next_token
resp = control.list_registry_records(**kwargs)
registered_names.update(r["name"] for r in resp["registryRecords"])
next_token = resp.get("nextToken")
if not next_token:
break
I originally reached for the provenance field here, expecting to link records back to their source AgentCore runtime ARN. Don't — create_registry_record rejects a caller-supplied provenance with ValidationException: provenance cannot be set by the caller. It's populated only by the service's own auto-detection/sync integrations, not by a plain API call. Matching on record name is simpler anyway, since names are unique within a registry.
Searching for duplicates. search_discoverable_registry_records does hybrid semantic + keyword search, ordered by relevance:
registry = boto3.client("agent-registry", region_name="us-east-1")
resp = registry.search_discoverable_registry_records(
searchQuery=f"{runtime_name} {runtime_description}",
registryIds=[registry_arn],
maxResults=3,
)
There's no numeric score in the response — just an ordered list. With a small registry (say, two or three approved records), that means it always returns something, even for genuinely unrelated runtimes, because there's nothing better to rank against. This isn't a bug so much as a reminder that semantic search needs a reasonably sized corpus to actually discriminate. It's also a decent argument for keeping a human in the approval loop rather than auto-rejecting on any hit.
Writing the approval. Three sequential calls: create, submit, approve.
created = control.create_registry_record(
registryId=registry_id,
name=runtime_name,
description=description,
recordType="CUSTOM",
recordVersion="1.0",
descriptors={"custom": {"data": json.dumps({"runtimeArn": runtime_arn})}},
)
record_id = created["recordArn"].split("/record/")[1] # not returned directly
# record starts CREATING and must reach DRAFT before you can submit it
for _ in range(15):
status = control.get_registry_record(registryId=registry_id, recordId=record_id)["status"]
if status == "DRAFT":
break
time.sleep(1)
control.submit_registry_record_for_approval(registryId=registry_id, recordId=record_id)
control.update_registry_record_status(
registryId=registry_id, recordId=record_id,
status="APPROVED", statusReason="Reviewed and approved",
)
Two things worth flagging: CreateRegistryRecordResponse only returns recordArn and status — no recordId field, so you extract it from the ARN — and creation is asynchronous, so a record submitted for approval before it leaves CREATING will fail.
Cross-referencing with AgentCore Runtime and CloudTrail
The other half of "shadow agent" detection is enumerating what's actually running, independent of the registry:
agentcore = boto3.client("bedrock-agentcore-control", region_name="us-east-1")
runtimes = agentcore.list_agent_runtimes()["agentRuntimes"]
The IAM action for this is bedrock-agentcore:ListAgentRuntimes — note the namespace is bedrock-agentcore, not bedrock-agentcore-control like the SDK package name would suggest. Getting this wrong produces a plain AccessDeniedException with no hint about the namespace mismatch.
For attribution — who deployed an unregistered runtime — CloudTrail's LookupEvents over CreateAgentRuntime gets you there, with the usual 90-day retention caveat:
cloudtrail = boto3.client("cloudtrail", region_name="us-east-1")
resp = cloudtrail.lookup_events(
LookupAttributes=[{"AttributeKey": "EventName", "AttributeValue": "CreateAgentRuntime"}],
)
for event in resp["Events"]:
detail = json.loads(event["CloudTrailEvent"])
arn = detail.get("responseElements", {}).get("agentRuntimeArn")
deployer = detail.get("userIdentity", {}).get("arn")
Infra notes
Agent Registry has no CDK construct as of September 2026 — no AWS::AgentRegistry::Registry CloudFormation resource type exists yet. I provisioned the registry and its seed records with the boto3 script above rather than CDK. AgentCore Runtime, by contrast, has a stable L2 construct (aws_bedrockagentcore.Runtime), and AgentRuntimeArtifact.fromCodeAsset() deploys straight from a local Python directory with no Docker step.
If you're running this on Vercel with OIDC federation to AWS: the OIDC provider is scoped per Vercel team, not per project. A second project under the same team hitting new iam.OpenIdConnectProvider(...) will fail deployment, since an AWS account only accepts one provider per issuer URL. Import the existing one with iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn() instead of creating a second.
Result
Scanning an account with a couple of intentionally-similar and intentionally-unrelated AgentCore runtimes: the similar one surfaces a real "possible duplicate" match against an existing approved record, and approving the unrelated one produces a genuine APPROVED record you can see with list-registry-records afterward — not a mock, the actual governance workflow AWS Agent Registry ships.

Top comments (0)