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.
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
Partobject withinline_datacontaining raw bytes and MIME type - Automatically versioned by the artifact service
- Available implementations:
InMemoryArtifactService(development) andGcsArtifactService(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 forapp_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.pdfis accessible across all sessions for a specific userreport.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:
- Save uploaded files as user-scoped artifacts automatically
- Access artifacts across sessions using the built-in
load_artifacttool - 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:
- Maintains compatibility: Leverages Google's tested artifact saving logic
- Stays upstream-aligned: Easy to update when ADK updates
- Minimizes code surface: Only changes what's necessary (the filename prefix)
- 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:
- Use descriptive filenames:
user:financial_report_2025.pdfis better thanuser:doc.pdf - Specify accurate MIME types: Helps the agent correctly interpret files
- Handle errors gracefully: Check if artifacts exist before loading
- Implement cleanup strategies: For persistent storage (GCS), consider lifecycle policies
- 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_artifacttool for complete file management
For more information about artifacts in Google ADK, visit the official ADK artifacts documentation.