AWS Strands Agents: User-Scoped Tools Without Breaking @tool Decorators

29 Jan 2026

If you’re building multi-user AI agents with AWS Strands SDK, you’ll quickly hit a common problem: how do you bind tools to a specific user without passing user_id in every query?

The obvious solution - functools.partial - silently breaks everything. Here’s why, and the pattern that actually works.

The Problem

Let’s say you have a tool that fetches user data:

from strands import tool

@tool
def get_user_data(user_id: str, days: int = 30) -> dict:
    """Get user's tracked health data."""
    return {"user_id": user_id, "entries": [...]}

You want to pre-bind user_id so the AI doesn’t need to know it:

from functools import partial

# This seems reasonable...
user_id = "550e8400-e29b-41d4-a716-446655440000"
scoped_tool = partial(get_user_data, user_id=user_id)

# Pass to agent
agent = Agent(model="...", tools=[scoped_tool])

This won’t work. The agent will have zero tools.

Why partial() Breaks Strands

The @tool decorator attaches metadata to your function that Strands uses for tool discovery, parameter extraction, and docstring parsing.

When you wrap with functools.partial(), you get a new partial object that doesn’t carry this metadata. Strands sees it as a random callable, not a tool.

from strands import tool
from functools import partial

@tool
def my_tool(user_id: str) -> dict:
    return {}

print(hasattr(my_tool, '__tool_metadata__'))  # True

scoped = partial(my_tool, user_id="123")
print(hasattr(scoped, '__tool_metadata__'))   # False - GONE!

The Solution: Closure-Based Scoping

Create new functions that capture user_id in a closure, then apply @tool:

from strands import tool
from typing import Dict, List, Callable

def create_user_scoped_tools(user_id: str) -> List[Callable]:
    """Create user-scoped tools with proper @tool decorators."""
    
    @tool
    def get_tracking_data(days: int = 30) -> Dict:
        """Get user's tracked health data for the past N days."""
        # user_id is captured in the closure - not a parameter!
        return _fetch_user_data(user_id, days)
    
    @tool  
    def log_symptom(symptom: str, severity: int = 5) -> Dict:
        """Log a symptom for the user. Severity is 1-10."""
        return _save_symptom(user_id, symptom, severity)
    
    return [get_tracking_data, log_symptom]

Each call creates fresh functions with:

Usage

from strands import Agent

def handle_user_request(user_id: str, message: str):
    tools = create_user_scoped_tools(user_id)
    
    agent = Agent(
        model="anthropic.claude-3-5-sonnet-20241022-v2:0",
        tools=tools
    )
    
    return agent(message)

Production Pattern with Database Transactions

Here’s a complete pattern with Django:

import logging
from typing import Dict, List, Callable
from datetime import timedelta
from django.db import connection, transaction
from django.utils import timezone
from strands import tool

logger = logging.getLogger(__name__)

def create_user_scoped_tools(user_id: str) -> List[Callable]:
    
    @tool
    def get_tracking_data(days: int = 30) -> Dict:
        """Get user's health tracking data."""
        try:
            cutoff = timezone.now() - timedelta(days=days)
            
            with connection.cursor() as cursor:
                cursor.execute('''
                    SELECT name, tracked_at, severity
                    FROM health_measurements
                    WHERE user_id = %s::uuid AND tracked_at >= %s
                    ORDER BY tracked_at DESC
                ''', [user_id, cutoff])
                
                entries = [
                    {"name": r[0], "date": r[1].isoformat(), "severity": r[2]}
                    for r in cursor.fetchall()
                ]
            
            # Prevent AI hallucination with explicit no-data response
            if not entries:
                return {
                    "status": "no_data",
                    "has_data": False,
                    "instruction": "Tell user: 'No data logged yet. Want to track something?'"
                }
            
            return {"status": "success", "has_data": True, "entries": entries}
            
        except Exception as e:
            logger.error(f"get_tracking_data failed: {e}")
            return {"status": "error", "message": str(e)}
    
    
    @tool
    def log_symptom(symptom_name: str, severity: int = 5) -> Dict:
        """Log a symptom. Severity is 1-10 scale."""
        import uuid
        
        try:
            # CRITICAL: transaction.atomic() ensures data persists
            with transaction.atomic():
                with connection.cursor() as cursor:
                    cursor.execute(
                        "SELECT id, name FROM symptoms WHERE name ILIKE %s LIMIT 1",
                        [f'%{symptom_name}%']
                    )
                    item = cursor.fetchone()
                    
                    if not item:
                        return {"status": "error", "message": f"Unknown: {symptom_name}"}
                    
                    cursor.execute('''
                        INSERT INTO health_measurements 
                        (id, symptom_id, user_id, severity, tracked_at)
                        VALUES (%s::uuid, %s::uuid, %s::uuid, %s, %s)
                    ''', [str(uuid.uuid4()), item[0], user_id, severity, timezone.now()])
                    
            return {"status": "success", "logged": item[1], "severity": severity}
            
        except Exception as e:
            return {"status": "error", "message": str(e)}
    
    return [get_tracking_data, log_symptom]

Key Patterns

Anti-Hallucination Responses

When tools return empty data, AI models sometimes invent data. Prevent this:

if not data:
    return {
        "status": "no_data",
        "has_data": False,
        "instruction": "Tell the user no data exists."
    }

Transaction Safety

Always wrap database writes:

from django.db import transaction

with transaction.atomic():
    cursor.execute("INSERT ...")

Common Mistakes

Mistake Symptom Fix
Using functools.partial Agent has 0 tools Use closure pattern
Missing transaction.atomic() Data doesn’t persist Wrap writes in transaction
Generic “no data” response AI hallucinates data Return explicit has_data: False
Not casting UUIDs in SQL invalid input syntax Use %s::uuid in Postgres

Conclusion

The functools.partial trap is easy to fall into - it’s the obvious Python solution. But Strands requires the @tool metadata that partial() strips away.

The closure pattern gives you:

AWS Strands is new enough that patterns are still emerging. Hope this saves you some debugging time.


Code available as a gist. MIT licensed.