By Lordpatil · · 4 min read

Creating User-Scoped Artifacts in Google ADK

Learn how to create user-scoped artifacts in Google ADK that persist across sessions, enabling better file management and user experience in AI agents.

AI agentsgoogle-adkadk-pythonartifactsai-agentspython

Creating User-Scoped Artifacts in Google ADK

When building AI agents with Google's Agent Development Kit (ADK), you often need to handle file uploads and persist them across user interactions. While ADK provides built-in artifact support through SaveFilesAsArtifactsPlugin, the default behavior creates session-scoped artifacts that can't be accessed across different sessions. This post shows how to create user-scoped artifacts that persist across all of a user's sessions.

Understanding Artifacts in Google ADK

Artifacts in Google ADK are named, versioned binary files that can be associated with either a specific session or a user across multiple sessions. They're the mechanism for managing binary data within ADK, representing files, images, and other binary formats beyond simple text.

Key Artifact Concepts

What are artifacts?

  • Binary data identified by a unique filename
  • Stored using a Part object with inline_data containing raw bytes and MIME type
  • Automatically versioned by the artifact service
  • Available implementations: InMemoryArtifactService (development) and GcsArtifactService (production)

Artifact Scoping: ADK uses filename prefixes to determine artifact scope:

  • Session-scoped (default): No prefix. Artifact tied to app_name + user_id + session_id
  • User-scoped: "user:" prefix. Accessible across all sessions for app_name + user_id

The Problem: Session-Scoped Limitations

The default SaveFilesAsArtifactsPlugin creates session-scoped artifacts. This means:

# Default behavior
app = App(
    name="my_agent",
    root_agent=root_agent,
    plugins=[SaveFilesAsArtifactsPlugin()],
)

When a user uploads a file, it's saved as filename.pdf (no prefix), making it:

  • Only accessible within the current session
  • Inaccessible in new sessions, even for the same user
  • Lost when the session ends

This is problematic for applications where users expect their uploaded files to persist across conversations. Imagine a document analysis agent where users upload a PDF in one session and want to reference it in a later session - the default behavior breaks this use case.

The Solution: User-Scoped Artifacts

The fix is simple: prefix artifact filenames with "user:". This tells ADK to scope the artifact to the user rather than the session.

For example:

  • user:contract.pdf is accessible across all sessions for a specific user
  • report.pdf (without prefix) is only accessible in its originating session

Implementation

Here's a complete implementation of UserScopedSaveFilesAsArtifactsPlugin that extends ADK's built-in plugin:

"""User-scoped variant of ADK's `SaveFilesAsArtifactsPlugin`.

Why:
- The upstream `SaveFilesAsArtifactsPlugin` uses the uploaded blob's
  `display_name` as the artifact filename (not just a UI label).
- In ADK, artifact scope is derived from the filename prefix:
  - No prefix: **session-scoped** (app_name + user_id + session_id)
  - "user:" prefix: **user-scoped** (app_name + user_id), shared across sessions

This plugin subclasses the upstream plugin and only rewrites `display_name`
to ensure the artifact is user-scoped, keeping behavior aligned with upstream
and easy to delete if ADK adds a first-class config knob later.
"""

from __future__ import annotations

import copy
from typing import cast

from google.adk.agents.invocation_context import InvocationContext
from google.adk.plugins.save_files_as_artifacts_plugin import SaveFilesAsArtifactsPlugin
from google.genai import types

_USER_NAMESPACE_PREFIX = "user:"


class UserScopedSaveFilesAsArtifactsPlugin(SaveFilesAsArtifactsPlugin):
    """Save uploaded files as user-scoped artifacts.

    We do this by prefixing the uploaded filename with `user:`.
    """

    async def on_user_message_callback(
        self,
        *,
        invocation_context: InvocationContext,
        user_message: types.Content,
    ) -> types.Content | None:
        """Prefix uploaded file names with `user:` and delegate to upstream plugin."""
        # Delegate edge-cases (including "no artifact service configured") to upstream.
        # Upstream returns:
        # - `user_message` if artifact_service is missing (plugin effectively disabled)
        # - `None` if message has no parts (no modifications)
        if not user_message.parts:
            return await super().on_user_message_callback(
                invocation_context=invocation_context,
                user_message=user_message,
            )

        # Rewrite only the inline file parts; non-file parts pass through untouched.
        prefixed_message = _prefix_inline_part_display_names(
            invocation_context=invocation_context,
            user_message=user_message,
        )

        # Run the official upstream implementation so we keep Google's behavior for:
        # - saving artifacts
        # - adding placeholder text
        # - appending a file reference part when model-accessible
        return await super().on_user_message_callback(
            invocation_context=invocation_context,
            user_message=prefixed_message,
        )


def _prefix_inline_part_display_names(
    *,
    invocation_context: InvocationContext,
    user_message: types.Content,
) -> types.Content:
    """Return a new user message where inline file parts are user-scoped.

    Notes:
    - We do not mutate the original `user_message` parts to avoid surprising
      side effects for other plugins/callbacks.
    - We only rewrite `inline_data.display_name` because upstream uses it as the
      artifact filename.
    """
    updated_parts: list[types.Part] = []
    did_change_any_part = False

    for part_index, original_part in enumerate(user_message.parts or []):
        # Only rewrite inline file parts. Text/tool parts should remain identical.
        if original_part.inline_data is None:
            updated_parts.append(original_part)
            continue

        # Compute the "user:"-prefixed filename we want upstream to use as the artifact
        # key. This is the only behavior change we introduce.
        desired_display_name = _desired_user_scoped_display_name(
            invocation_context=invocation_context,
            original_display_name=original_part.inline_data.display_name,
            part_index=part_index,
        )

        # If already user-scoped, keep the original part object (no copying).
        if desired_display_name == original_part.inline_data.display_name:
            updated_parts.append(original_part)
            continue

        # Never mutate the original part; other plugins/callbacks may observe it.
        # Deepcopy is safe here because `types.Part` can contain nested objects.
        copied_part = copy.deepcopy(original_part)
        # `copy.deepcopy()` preserves `inline_data`. We already checked the original
        # part has inline data, but mypy can't infer that, so we cast.
        copied_inline_data = cast(types.Blob, copied_part.inline_data)
        copied_inline_data.display_name = desired_display_name
        updated_parts.append(copied_part)
        did_change_any_part = True

    # If nothing changed, preserve object identity to minimize downstream surprises.
    if not did_change_any_part:
        return user_message

    # Return a new Content object only when we actually rewrote something.
    return types.Content(role=user_message.role, parts=updated_parts)


def _desired_user_scoped_display_name(
    *,
    invocation_context: InvocationContext,
    original_display_name: str | None,
    part_index: int,
) -> str:
    """Compute the filename that will cause ADK to store a user-scoped artifact."""
    # Already user-scoped; keep as-is.
    if original_display_name and original_display_name.startswith(
        _USER_NAMESPACE_PREFIX
    ):
        return original_display_name

    # Normal case: prefix the uploaded filename.
    if original_display_name:
        return f"{_USER_NAMESPACE_PREFIX}{original_display_name}"

    # If the client did not provide a name, upstream would generate
    # `artifact_{invocation_id}_{part_index}`. We mirror that, but user-scope it.
    invocation_id = invocation_context.invocation_id
    return f"{_USER_NAMESPACE_PREFIX}artifact_{invocation_id}_{part_index}"

How to Use It

Using the plugin is straightforward. Simply replace SaveFilesAsArtifactsPlugin with UserScopedSaveFilesAsArtifactsPlugin in your app configuration:

from google.adk import App
from google.adk.plugins import GlobalInstructionPlugin, LoggingPlugin

app = App(
    name="transaction_coordinator",
    root_agent=root_agent,
    plugins=[
        GlobalInstructionPlugin(return_global_instruction),
        LoggingPlugin(),
        UserScopedSaveFilesAsArtifactsPlugin(),  # Use the user-scoped variant
    ],
    events_compaction_config=None,
    context_cache_config=None,
    resumability_config=None,
)

That's it! Your agent can now:

  1. Save uploaded files as user-scoped artifacts automatically
  2. Access artifacts across sessions using the built-in load_artifact tool
  3. Maintain file persistence for better user experience

Example Usage Flow

# Session 1: User uploads a file
# The file is saved as "user:contract.pdf"

# Session 2 (same user, different session):
# User: "Can you analyze the contract I uploaded earlier?"
# Agent can use load_artifact("user:contract.pdf") to retrieve it

Technical Details and Design Decisions

Why Subclass Instead of Reimplementing?

This implementation subclasses the upstream SaveFilesAsArtifactsPlugin rather than reimplementing its logic. This approach:

  1. Maintains compatibility: Leverages Google's tested artifact saving logic
  2. Stays upstream-aligned: Easy to update when ADK updates
  3. Minimizes code surface: Only changes what's necessary (the filename prefix)
  4. Future-proof: Can be easily removed if ADK adds a first-class config option

Important Behavior Note

Because upstream uses display_name for both saving and placeholder text, the placeholder shown to users may include the user: prefix. For example:

File uploaded: user:contract.pdf

If you need the placeholder to show only the original filename, you would need to implement a fully custom plugin that decouples "display name" from "save name." For most use cases, the prefix in the UI is acceptable and even helpful as it signals to users that their file is persisted.

Immutability Guarantee

The implementation uses copy.deepcopy() to ensure the original user_message parts are never mutated. This prevents surprising side effects for other plugins or callbacks that might be observing the same message object.

Best Practices

When working with user-scoped artifacts:

  1. Use descriptive filenames: user:financial_report_2025.pdf is better than user:doc.pdf
  2. Specify accurate MIME types: Helps the agent correctly interpret files
  3. Handle errors gracefully: Check if artifacts exist before loading
  4. Implement cleanup strategies: For persistent storage (GCS), consider lifecycle policies
  5. Be mindful of storage costs: User-scoped artifacts persist indefinitely

Artifact Storage Configuration

ADK supports two artifact service implementations:

from google.adk.artifact_services import InMemoryArtifactService, GcsArtifactService

# Development: In-memory (temporary)
artifact_service = InMemoryArtifactService()

# Production: Google Cloud Storage (persistent)
artifact_service = GcsArtifactService(
    bucket_name="my-artifacts-bucket",
    # Artifacts organized by app_name/user_id for user-scoped
)

Conclusion

User-scoped artifacts are essential for building AI agents with meaningful file persistence. By prefixing filenames with "user:", you can create artifacts that survive across sessions, enabling richer user experiences.

The UserScopedSaveFilesAsArtifactsPlugin provides a simple, maintainable solution that integrates seamlessly with ADK's ecosystem while waiting for potential first-class support in future ADK releases.

Key Takeaways

  • Default ADK artifacts are session-scoped and don't persist
  • User-scoped artifacts use the "user:" filename prefix
  • The custom plugin requires minimal code by extending upstream behavior
  • Integration is as simple as swapping the plugin in your App configuration
  • Combine with the built-in load_artifact tool for complete file management

For more information about artifacts in Google ADK, visit the official ADK artifacts documentation.