Agents produce work, not just words: a chart rendered by a code tool, a spreadsheet pulled from a database, a PDF the user uploaded, a generated image. In the Agent Development Kit for Java these binary outputs are artifacts: named, versioned blobs stored by an artifact service that sits beside the session store rather than inside it.
This article explains the artifact system from the interface down to the storage layout, using the behaviour of the ADK Java source itself. You will see how to wire a service into a runner, write a tool that saves and loads artifacts, let the model read them on demand, and which failure modes to design for, including a version race in the stock Cloud Storage implementation that you should know about before running it with concurrent writers.
Why agents need a separate store for files
Session state and the event transcript are replayed into the model on every turn, so they must stay small and textual. Work product is large, binary and long-lived. If a tool returns a 3 MB image as base64 inside its response, that image enters the conversation history and is re-sent with every later model call, costing tokens and latency each time, and any database-backed session store must hold it in a row.
Artifacts split the two. A tool saves the bytes through the artifact service and tells the model only something short, such as "saved revenue.png as version 2". The model reasons over names; bytes move only when something actually needs them, for example when the model must look at the image or the user downloads it. A session that produced hundreds of megabytes of files can still replay in a few thousand tokens.
Why agents need a separate store for files
Session state and the event transcript are replayed into the model on every turn, so they must stay small and textual. Work product is large, binary and long-lived. If a tool returns a 3 MB image as base64 inside its response, that image enters the conversation history and is re-sent with every later model call, costing tokens and latency each time, and any database-backed session store must hold it in a row.
Artifacts split the two. A tool saves the bytes through the artifact service and tells the model only something short, such as "saved revenue.png as version 2". The model reasons over names; bytes move only when something actually needs them, for example when the model must look at the image or the user downloads it. A session that produced hundreds of megabytes of files can still replay in a few thousand tokens.
The contract: BaseArtifactService
The interface is com.google.adk.artifacts.BaseArtifactService. It is reactive (RxJava 3) and every call is scoped by application name, user id and session id. The abstract methods are:
Single<Integer> saveArtifact(String appName, String userId, String sessionId,
String filename, Part artifact);
Maybe<Part> loadArtifact(String appName, String userId, String sessionId,
String filename, @Nullable Integer version); // null = latest
Single<ListArtifactsResponse> listArtifactKeys(String appName, String userId, String sessionId);
Completable deleteArtifact(String appName, String userId, String sessionId, String filename);
Single<ImmutableList<Integer>> listVersions(String appName, String userId, String sessionId,
String filename);Default overloads accept a SessionKey instead of the three strings, load the latest version without a version argument, and saveAndReloadArtifact saves and returns the stored Part.
Three design decisions matter. First, the stored unit is a com.google.genai.types.Part, the same type the model API uses, typically built with Part.fromBytes(bytes, mimeType), so an artifact can be attached to a model request without conversion. Second, saves are append-only: each save of a filename creates the next integer version, starting at 0, and earlier versions stay readable. Third, a load of a missing artifact completes empty rather than erroring, which is why it returns a Maybe.
Scopes and storage layout
Filenames have two scopes. A plain name such as draft_report.pdf belongs to one session and is invisible to the user's other sessions. A name that starts with user:, such as user:profile_photo.png, belongs to the user across every session of the app. That is how an agent can find a document uploaded last week in a new conversation.
Both built-in implementations honour the prefix. InMemoryArtifactService keeps nested maps keyed by app, user, session and filename, each holding a list of Part versions, and routes user-scoped names to a shared per-user bucket. GcsArtifactService stores one object per version:
<appName>/<userId>/<sessionId>/<filename>/<version> session scope
<appName>/<userId>/user/<filename>/<version> "user:" scopelistArtifactKeys merges both scopes, so a listing shows the session's files and the user's long-lived files together. A filename containing slashes becomes extra path segments in the bucket, so keep names simple and validate any name that comes from the model or the user.
Wiring a service into the runner
The runner owns the artifact service and passes it to every invocation. If you do not set one, the builder defaults to InMemoryArtifactService, which is right for tests and wrong for anything with more than one replica or a restart.
Storage storage = StorageOptions.getDefaultInstance().getService();
BaseArtifactService artifacts = new GcsArtifactService("acme-agent-artifacts", storage);
Runner runner = Runner.builder()
.agent(rootAgent)
.appName("analytics-app")
.sessionService(sessionService)
.artifactService(artifacts)
.build();Tools and callbacks never see the raw service. CallbackContext exposes saveArtifact(filename, part), loadArtifact(filename), loadArtifact(filename, version) and listArtifacts(), with the app, user and session filled in from the current invocation, and ToolContext extends CallbackContext. A tool author therefore cannot accidentally write into another user's namespace.
Writing a tool that saves artifacts
Here is a tool that renders a chart and saves it. FunctionTool injects the context into a parameter named toolContext. The name is read from the @Schema annotation if present, otherwise from the compiled parameter name, so annotate it rather than relying on the -parameters compiler flag. Note that CallbackContext.saveArtifact returns a Completable, not the version number. On success it records the filename and new version in the current event's artifactDelta, which is how the session transcript remembers which version a tool produced.
public class ChartTools {
@Annotations.Schema(description = "Render a revenue chart and save it as an artifact")
public static Single<Map<String, Object>> renderRevenueChart(
@Annotations.Schema(name = "quarter", description = "e.g. 2026-Q3") String quarter,
@Annotations.Schema(name = "toolContext") ToolContext toolContext) {
byte[] png = ChartRenderer.revenueByRegion(quarter); // your code
String name = "revenue_" + quarter + ".png";
Part part = Part.fromBytes(png, "image/png");
return toolContext.saveArtifact(name, part)
.andThen(toolContext.listArtifacts())
.map(names -> Map.<String, Object>of(
"status", "saved",
"artifact", name,
"bytes", png.length,
"artifacts_in_scope", names.size()));
}
}
FunctionTool chartTool = FunctionTool.create(ChartTools.class, "renderRevenueChart");The model sees a few dozen bytes of JSON, never the PNG. If a downstream component needs the exact version, read it from the event's artifactDelta or call the service's listVersions.
Getting artifacts in front of the model
Two built-in mechanisms move artifacts toward the model.
Uploads. When RunConfig enables saveInputBlobsAsArtifacts, the runner walks the incoming user message, saves every part that carries inline data as an artifact named artifact_<invocationId>_<index>, and replaces the bytes in the message with a short text placeholder saying the file was uploaded and saved. The transcript keeps a pointer instead of the upload.
On-demand loading. Adding LoadArtifactsTool.INSTANCE to an agent's tools gives the model a function called load_artifacts with an artifact_names list argument. The tool tells the model which artifacts exist; when the model calls it, the requested artifacts are attached to the next request as content, with unsupported MIME types converted to text or summarised. The bytes reach the model only for the turn that needs them.
Worked example: one analysis session
Follow one analysis session end to end. The user uploads sales.csv and asks for a regional revenue chart. With input blobs saved as artifacts, the runner stores the file as artifact_<invocationId>_0 version 0 and the model sees only the placeholder. The model calls load_artifacts to inspect the file's header rows, then calls renderRevenueChart. The tool writes analytics-app/user-42/s-9f3/revenue_2026-Q3.png/0 to the bucket, the event records {revenue_2026-Q3.png: 0} in its artifact delta, and the model answers with a short description. The user asks for the chart in euros; the tool saves version 1 of the same name and version 0 remains readable for comparison. Your UI lists the session's artifact keys and offers the latest version for download by loading it through your backend, which also enforces that the requesting user owns the namespace.
Failure modes and how to guard against them
The version race. GcsArtifactService computes the next version by listing the existing version objects and taking the maximum plus one, then writes the object with a plain create call that carries no doesNotExist precondition. Two concurrent saves of the same filename can both compute version 3, and the second write silently replaces the first. Do not rely on append-only semantics under concurrency unless you add a guard. Parallel agents writing the same filename are the classic trigger.
// Sketch of a guarded write for a custom BaseArtifactService on GCS.
int next = maxExistingVersion(prefix) + 1; // list; fail the save on listing errors
for (int attempt = 0; attempt < 5; attempt++) {
BlobInfo info = BlobInfo.newBuilder(BlobId.of(bucket, prefix + next))
.setContentType(mimeType).build();
try {
storage.create(info, bytes, Storage.BlobTargetOption.doesNotExist());
return next; // we own this version
} catch (StorageException e) {
if (e.getCode() != 412) throw e; // 412 = precondition failed: someone took it
next++;
}
}
throw new IllegalStateException("could not allocate artifact version");- In-memory loss.
InMemoryArtifactServicekeeps every version of every Part on the heap, never evicts, and forgets everything on restart. Fine for tests; a slow memory leak and a data-loss bug in production. - Whole-object loads. A
Partcarries its bytes in memory, so a 500 MB artifact is a 500 MB allocation per load. Cap artifact size in tools and keep very large data in your own storage, saving only a reference or summary as the artifact. - Unbounded growth. Nothing in the interface expires old versions or abandoned sessions. Add bucket lifecycle rules and an explicit
deleteArtifactstep when you delete a session, remembering that user-scoped files outlive sessions by design. - Context blow-up through the back door. Loading artifacts into every request, or returning file contents from tools, re-creates the problem artifacts solve. Load on demand and return summaries.
- Untrusted names and content. Model-chosen filenames can collide with or overwrite real files, and uploaded documents can carry prompt-injection text. Validate names and treat loaded content as data, not instructions.
Testing artifact behaviour
Because the service is an interface, artifact behaviour is easy to pin down in unit tests before any agent or model is involved. Test the contract you depend on directly against InMemoryArtifactService, and run the same test class against your production implementation with an emulator or a scratch bucket, so a backend swap cannot quietly change semantics.
@Test
void savesAppendOnlyVersionsAndScopesUserFiles() {
BaseArtifactService svc = new InMemoryArtifactService();
Part v0 = Part.fromBytes("a,b\n1,2".getBytes(UTF_8), "text/csv");
Part v1 = Part.fromBytes("a,b\n3,4".getBytes(UTF_8), "text/csv");
assertEquals(0, svc.saveArtifact("app", "u1", "s1", "data.csv", v0).blockingGet());
assertEquals(1, svc.saveArtifact("app", "u1", "s1", "data.csv", v1).blockingGet());
assertEquals(List.of(0, 1), svc.listVersions("app", "u1", "s1", "data.csv").blockingGet());
// null version means latest; an old version stays readable
assertEquals(v1, svc.loadArtifact("app", "u1", "s1", "data.csv", null).blockingGet());
assertEquals(v0, svc.loadArtifact("app", "u1", "s1", "data.csv", 0).blockingGet());
// session scope is private; user: scope is shared across the user's sessions
assertNull(svc.loadArtifact("app", "u1", "s2", "data.csv", null).blockingGet());
svc.saveArtifact("app", "u1", "s1", "user:prefs.json", v0).blockingGet();
assertNotNull(svc.loadArtifact("app", "u1", "s2", "user:prefs.json", null).blockingGet());
}For tools, construct the runner with an in-memory service, drive one turn with a scripted or recorded model response that calls the tool, then assert on two things: the artifact exists with the expected MIME type and version, and the tool response the model saw is a short summary rather than the file contents. That second assertion is the one that catches a regression where somebody starts returning bytes to the model again. Add one concurrency test against your production service that fires two saves of the same filename at once and asserts both versions survive; with the stock GCS service and no guard, it is the test that tells you the race is real in your environment.
Artifacts versus state versus memory
| Store | Holds | Lifetime | Seen by the model |
|---|---|---|---|
| Session state | small key-value facts | the session (prefixes widen it) | via instructions and tools |
| Event transcript | messages, tool calls, deltas | the session | replayed every turn |
| Artifacts | binary files with versions | session, or user with user: | only when loaded |
| Memory service | searchable knowledge across sessions | long-term | only when searched |
The rule of thumb: if it is large, binary, or needs history, it is an artifact; if the model needs it on every turn, it is state; if it must be found by meaning across sessions, it belongs in memory.
What to do next
- Replace the default in-memory service with a durable one in every non-test runner.
- Audit tools that return file bytes or long text and make them save artifacts and return summaries.
- Decide per file whether it is session-scoped or
user:-scoped, and document it. - Add
LoadArtifactsTool.INSTANCEwhere the model must inspect files, and enablesaveInputBlobsAsArtifactsfor uploads. - If any filename can be written concurrently, add a precondition-guarded save or serialise writes.
- Set size limits, bucket lifecycle rules and deletion on session removal.
- Read on: ADK Java callbacks, writing a FunctionTool, events, multimodal agents and session context in depth.