,

S3 Annotations: The Metadata AWS Finally Got Right

S3 Annotations with AI agent data discovery visualization

S3 Annotations is the feature that makes you wonder why AWS took this long to ship it — you’ve been bolting on metadata sidecars, maintaining DynamoDB tables full of object pointers, and writing increasingly baroque Lambda functions just so an AI agent could figure out which S3 objects it actually cares about. That ends now. Announced at AWS Summit New York on June 17, 2026, S3 Annotations lets you attach up to 1 GB of structured, mutable, queryable context directly to any S3 object — no external catalog, no DynamoDB crutch, no philosophical debate about where the metadata actually lives.

Why S3’s Existing Metadata Options Are Genuinely Bad

Let’s be honest about the status quo before we get excited. S3 had two ways to attach context to objects before Annotations dropped, and both are embarrassingly limited for anything involving AI workloads.

Object metadata is set at upload time, is immutable after the fact (you’d have to copy the object to change it), and is capped at 2 KB. Two kilobytes. That’s a haiku, not a schema.

Object tags are a little better — you can update them without touching the object — but you’re limited to 10 key-value pairs, and the value is just a string. You can’t express relationships, you can’t nest structures, and if your AI agent needs to understand something beyond “env=prod”, you’re already out of room.

The workaround engineers have been using is maintaining an external metadata store — typically DynamoDB or OpenSearch — keyed on the S3 object URI. It works, but you’re now running a second system that can drift out of sync, requires its own access control, and adds latency and cost to every agent query. S3 Annotations kills that workaround dead.

What S3 Annotations Actually Give You

Annotations are a first-class S3 construct. They live alongside the object in S3 itself, they’re versioned (if your bucket has versioning enabled), they’re subject to the same bucket policies and IAM controls as the object, and they can be updated independently of the object data — meaning you can annotate an object after the fact without triggering a copy operation.

The key characteristics:

  • Size limit: Up to 1 GB per object. That’s enough to store full document embeddings, lineage graphs, processing histories, or whatever your agents need.
  • Format: Structured JSON. You define the schema. AWS enforces valid JSON syntax, not your schema — so design carefully.
  • Mutability: Fully mutable after upload. Update annotations without touching the object itself.
  • Queryability: Query via the S3 API to find objects whose annotations match criteria. This is the game-changer for agent discoverability.
  • Access control: Standard IAM and bucket policies — no separate permissions model to manage.

The API Shape: PutObjectAnnotation and GetObjectAnnotation

The new API calls are straightforward. Here’s the boto3 signature for writing annotations:

import boto3
import json

s3 = boto3.client('s3', region_name='us-east-1')

annotation_payload = {
    "schema_version": "1.0",
    "content_type": "financial_report",
    "document_metadata": {
        "fiscal_year": 2025,
        "quarter": "Q4",
        "entity": "Acme Corp",
        "currency": "USD"
    },
    "processing_state": {
        "status": "raw",
        "last_processed": None,
        "agent_ready": True
    },
    "embeddings_ref": "s3://embeddings-bucket/acme-q4-2025.npy",
    "tags": ["earnings", "10-K", "public-filing"],
    "lineage": {
        "source_system": "edgar-ingestion-pipeline",
        "ingest_timestamp": "2026-06-20T14:32:00Z",
        "checksum": "sha256:a1b2c3d4..."
    }
}

# Write the annotation
response = s3.put_object_annotation(
    Bucket='my-data-lake',
    Key='financials/acme-q4-2025.pdf',
    Annotation=json.dumps(annotation_payload)
)

print(f"Annotation ETag: {response['AnnotationETag']}")

Reading it back is equally clean:

# Retrieve annotation
response = s3.get_object_annotation(
    Bucket='my-data-lake',
    Key='financials/acme-q4-2025.pdf'
)

annotation = json.loads(response['Annotation'].read())
print(json.dumps(annotation, indent=2))

You can also use the AWS CLI for quick inspection during development:

# Put annotation via CLI
aws s3api put-object-annotation \
  --bucket my-data-lake \
  --key financials/acme-q4-2025.pdf \
  --annotation file://annotation.json

# Get annotation via CLI
aws s3api get-object-annotation \
  --bucket my-data-lake \
  --key financials/acme-q4-2025.pdf \
  --output json | jq '.Annotation | fromjson'
AI agent querying S3 annotations in a data pipeline

Querying Annotations: How Agents Discover Objects

The real power is in annotation-aware queries. Instead of pulling a full object listing and filtering client-side (which doesn’t scale, and you know it), agents can issue structured queries against annotation fields directly through the S3 API:

# Query objects with annotation matching criteria
response = s3.query_objects_by_annotation(
    Bucket='my-data-lake',
    AnnotationQuery={
        "Filter": {
            "And": [
                {
                    "Field": "content_type",
                    "Operator": "Equals",
                    "Value": "financial_report"
                },
                {
                    "Field": "processing_state.agent_ready",
                    "Operator": "Equals",
                    "Value": True
                },
                {
                    "Field": "document_metadata.fiscal_year",
                    "Operator": "GreaterThanOrEquals",
                    "Value": 2025
                }
            ]
        }
    },
    MaxKeys=100
)

for obj in response['Objects']:
    print(f"Key: {obj['Key']}, Annotation summary: {obj.get('AnnotationSummary')}")

This is what turns your data lake into something an autonomous agent can actually navigate without either maintaining a sidecar index or doing embarrassingly expensive full-bucket scans.

Designing Annotation Schemas for AI Agent Discoverability

Since you define the JSON schema, get this right upfront. A few principles that will save you from schema debt later:

1. Include a schema_version field always. Your annotation format will evolve. Agents need to handle multiple schema versions gracefully.

2. Separate discovery fields from processing fields. Fields an agent queries to find objects (content_type, status, entity) should be shallow and well-indexed. Heavy payload data (embeddings, full lineage graphs) can live deeper or be referenced by pointer.

3. Use processing_state as a task queue signal. Instead of a separate queue, let agents claim work by querying for processing_state.status == "raw" and then updating the annotation to processing_state.status == "in_progress" with an agent ID. It’s a dead-simple distributed work coordination pattern.

4. Store embedding references, not embeddings. Even at 1 GB, storing full embedding vectors in annotations on every object isn’t the move. Store a pointer to the embedding artifact. The annotation’s job is discoverability, not storage.

Wiring Into Bedrock AgentCore Pipelines

In a Bedrock AgentCore workflow, you’d expose annotation queries as a tool the agent can call. Here’s a minimal tool definition that lets the agent discover unprocessed financial documents:

import boto3
import json

def discover_unprocessed_documents(content_type: str, fiscal_year: int) -> list:
    """
    Tool function exposed to Bedrock AgentCore.
    Returns S3 keys of objects ready for agent processing.
    """
    s3 = boto3.client('s3')
    
    response = s3.query_objects_by_annotation(
        Bucket='my-data-lake',
        AnnotationQuery={
            "Filter": {
                "And": [
                    {"Field": "content_type", "Operator": "Equals", "Value": content_type},
                    {"Field": "document_metadata.fiscal_year", "Operator": "Equals", "Value": fiscal_year},
                    {"Field": "processing_state.agent_ready", "Operator": "Equals", "Value": True},
                    {"Field": "processing_state.status", "Operator": "Equals", "Value": "raw"}
                ]
            }
        }
    )
    
    return [{"key": obj["Key"], "bucket": "my-data-lake"} for obj in response["Objects"]]


def claim_document_for_processing(bucket: str, key: str, agent_id: str) -> bool:
    """
    Atomically claim an object for processing by updating its annotation.
    """
    s3 = boto3.client('s3')
    
    # Get current annotation
    current = json.loads(s3.get_object_annotation(Bucket=bucket, Key=key)['Annotation'].read())
    
    # Update processing state
    current['processing_state']['status'] = 'in_progress'
    current['processing_state']['claimed_by'] = agent_id
    current['processing_state']['claim_timestamp'] = '2026-06-29T06:00:00Z'
    
    s3.put_object_annotation(
        Bucket=bucket,
        Key=key,
        Annotation=json.dumps(current)
    )
    
    return True

Register these as AgentCore tools and your agent can now coordinate across a multi-agent pipeline using nothing but S3 and its annotations. No SQS, no DynamoDB, no “just add another microservice” nonsense.

IAM Considerations

Annotations are controlled by new IAM action verbs. You’ll need to explicitly grant these — they’re not covered by existing s3:GetObject or s3:PutObject permissions:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObjectAnnotation",
        "s3:PutObjectAnnotation",
        "s3:DeleteObjectAnnotation",
        "s3:QueryObjectsByAnnotation"
      ],
      "Resource": "arn:aws:s3:::my-data-lake/*"
    }
  ]
}

Keep annotation write permissions tight — particularly s3:PutObjectAnnotation. In a multi-agent system, you don’t want every agent able to overwrite every annotation. Scope by prefix at minimum.

The Bottom Line

S3 Annotations is the kind of feature that makes you realize how much scaffolding you’ve been building around a fundamental gap. If you’re running AI agents that need to discover, understand, and act on data in S3 — and at this point, who isn’t — this removes the most annoying part of the plumbing. The 1 GB cap is generous, the JSON schema flexibility is appropriate, and the query API means agents can actually navigate your data lake like they’re supposed to. About time.

At Enkompass (enkompass.net), this is our home turf. We pressure-test new AWS services in real client environments the week they drop, so you get the upside without becoming the cautionary tale. If you’d rather have seasoned cloud people handle it instead of learning the hard way at 2 AM, let’s talk. Reach out through our contact page or call us directly at +1-828-436-5667 — we’ll help you turn this from a someday-project into a done-deal.

Ready when you are

Let’s map your route to AWS

A free 60-minute architecture review with a senior engineer. No slides, no SDR, just a plan.