Agents that work with documents need somewhere to keep them: reports they generate, files users hand over, intermediate data shared between steps. On Google Cloud that place is usually Cloud Storage (GCS). In the Agent Development Kit for Java, GCS appears in two different roles, and confusing them causes most of the design mistakes.

The first role is the artifact service: GcsArtifactService stores versioned, session-scoped blobs on behalf of the framework, and tools reach it through ToolContext.saveArtifact. That role, its object layout and its concurrency caveat are covered in ADK Java artifacts. The second role, the subject of this article, is GCS as a set of tools the model can call: list a folder, read a file, write a result, delete a draft, hand a user a download link. Here the model chooses object names, so the code must decide what those names are allowed to touch. This article builds those tools with the google-cloud-storage Java client, explains generation preconditions and V4 signed URLs, and covers the IAM, encryption and lifecycle settings around them.

The architecture

Cloud Storage as agent tools: the model names objects, the code owns the namespaceUser / UIchat + download linkLlmAgentdecides which toolGcsObjectToolslist, read, write, delete, sharecallNamespace guardprefix = users/{userId}/Storage clientADC, one instanceGCS bucketuniform access, CMEK, lifecycle, soft deleteifGenerationMatchV4 signed URLshort expiry, one object, one methodbrowser fetchService account: object roles on this bucket only, plus signBlob on itself for signing
The model passes relative names; the tool class turns them into objects under a per-user prefix, writes with preconditions, and hands out short-lived signed URLs instead of bytes.

Three rules shape the design. The model never chooses the bucket or the prefix. The bucket is configuration and the prefix comes from the invocation's user id, so a prompt injection cannot make the agent read another user's files. Bytes stay out of the context window. Tools return metadata, bounded text excerpts or links, never large or binary content. Every write is conditional. GCS gives each object version a generation number, and writes carry the generation the agent last saw, so the agent cannot silently overwrite a change it has not read.

The namespace rule

GCS has no directories; an object name is a flat string up to 1,024 bytes of UTF-8, and slashes are just characters that tools display as folders. Path traversal in the file-system sense does not exist, but name confusion does: a name such as ../other-user/x is stored literally and can surprise anything that later maps names to paths. Validate names strictly and build the full object name in one place.

The user id is available from the tool context: ToolContext extends CallbackContext, which extends ReadonlyContext, whose userId() returns the id the runner was called with. That id is only as trustworthy as the code that set it, so make sure your server derives it from the authenticated caller, not from a request field.

final class Namespace {
  private static final Pattern NAME = Pattern.compile("[A-Za-z0-9._\\-/]{1,200}");

  static String objectName(ToolContext ctx, String relative) {
    if (relative == null || !NAME.matcher(relative).matches()
        || relative.startsWith("/") || relative.contains("//")
        || Arrays.asList(relative.split("/")).contains("..")) {
      throw new IllegalArgumentException("INVALID_NAME");
    }
    return prefix(ctx) + relative;
  }

  static String prefix(ToolContext ctx) {
    String uid = ctx.userId();
    if (!uid.matches("[A-Za-z0-9_-]{1,64}")) throw new IllegalStateException("bad user id");
    return "users/" + uid + "/";
  }
}

Building the tools

The tool class holds one Storage client, created once from Application Default Credentials and shared; the client is thread-safe and expensive to construct. Each method returns a small map, with an error code the model can act on instead of a stack trace.

public class GcsObjectTools {
  private static final long MAX_READ = 256 * 1024;      // bytes the model may see
  private final Storage storage;
  private final String bucket;

  public GcsObjectTools(Storage storage, String bucket) {
    this.storage = storage; this.bucket = bucket;
  }

  @Schema(description = "List files under a folder of the user's storage. "
      + "Pass pageToken from a previous result to continue.")
  public Map<String, Object> listFiles(
      @Schema(name = "folder", description = "Relative folder, e.g. reports/ or empty") String folder,
      @Schema(name = "pageToken", description = "Token from a previous call, or empty") String pageToken,
      @Schema(name = "toolContext") ToolContext toolContext) {
    String prefix = folder == null || folder.isEmpty()
        ? Namespace.prefix(toolContext) : Namespace.objectName(toolContext, folder);
    List<Storage.BlobListOption> opts = new ArrayList<>(List.of(
        Storage.BlobListOption.prefix(prefix),
        Storage.BlobListOption.currentDirectory(),
        Storage.BlobListOption.pageSize(50)));
    if (pageToken != null && !pageToken.isEmpty()) opts.add(Storage.BlobListOption.pageToken(pageToken));
    Page<Blob> page = storage.list(bucket, opts.toArray(new Storage.BlobListOption[0]));
    List<Map<String, Object>> items = new ArrayList<>();
    for (Blob b : page.getValues()) {
      items.add(Map.of("name", b.getName().substring(Namespace.prefix(toolContext).length()),
          "size", b.getSize() == null ? 0L : b.getSize(),
          "generation", b.getGeneration() == null ? 0L : b.getGeneration()));
    }
    return Map.of("items", items, "nextPageToken", Objects.toString(page.getNextPageToken(), ""));
  }

  @Schema(description = "Read a UTF-8 text file. Returns its generation for later writes.")
  public Map<String, Object> readText(
      @Schema(name = "name", description = "Relative file name") String name,
      @Schema(name = "toolContext") ToolContext toolContext) {
    Blob meta = storage.get(BlobId.of(bucket, Namespace.objectName(toolContext, name)));
    if (meta == null) return Map.of("error", "NOT_FOUND");
    if (meta.getSize() > MAX_READ) return Map.of("error", "TOO_LARGE", "size", meta.getSize());
    byte[] bytes = storage.readAllBytes(meta.getBlobId(),
        Storage.BlobSourceOption.generationMatch(meta.getGeneration()));   // the version we sized
    return Map.of("text", new String(bytes, StandardCharsets.UTF_8),
                  "generation", meta.getGeneration());
  }

  @Schema(description = "Create or replace a text file. To replace, pass the generation "
      + "returned by readText; pass 0 to create a new file.")
  public Map<String, Object> writeText(
      @Schema(name = "name", description = "Relative file name") String name,
      @Schema(name = "content", description = "Full file content") String content,
      @Schema(name = "expectedGeneration", description = "Generation you read, or 0 for new") Long expectedGeneration,
      @Schema(name = "toolContext") ToolContext toolContext) {
    if (expectedGeneration == null) expectedGeneration = 0L;   // the model may omit it
    BlobInfo info = BlobInfo.newBuilder(bucket, Namespace.objectName(toolContext, name))
        .setContentType("text/plain; charset=utf-8").build();
    Storage.BlobTargetOption pre = expectedGeneration == 0
        ? Storage.BlobTargetOption.doesNotExist()
        : Storage.BlobTargetOption.generationMatch(expectedGeneration);
    try {
      Blob b = storage.create(info, content.getBytes(StandardCharsets.UTF_8), pre);
      return Map.of("generation", b.getGeneration());
    } catch (StorageException e) {
      if (e.getCode() == 412) return Map.of("error", "STALE_WRITE",
          "hint", "The file changed or already exists; read it again first");
      throw e;
    }
  }

  @Schema(description = "Delete a file. Pass the generation returned by readText or listFiles.")
  public Map<String, Object> deleteFile(
      @Schema(name = "name", description = "Relative file name") String name,
      @Schema(name = "expectedGeneration", description = "Generation you last saw") Long expectedGeneration,
      @Schema(name = "toolContext") ToolContext toolContext) {
    if (expectedGeneration == null || expectedGeneration == 0L) return Map.of("error", "GENERATION_REQUIRED");
    BlobId id = BlobId.of(bucket, Namespace.objectName(toolContext, name));
    try {
      boolean deleted = storage.delete(id, Storage.BlobSourceOption.generationMatch(expectedGeneration));
      return deleted ? Map.of("deleted", true) : Map.of("error", "NOT_FOUND");
    } catch (StorageException e) {
      if (e.getCode() == 412) return Map.of("error", "STALE_DELETE", "hint", "The file changed; read it again first");
      throw e;
    }
  }

  // shareLink (next section) is the fifth method of this class.
}

storage.delete returns false when the object is already gone. Register the delete tool with human confirmation, as in ADK Java file tools:

Storage storage = StorageOptions.getDefaultInstance().getService();
GcsObjectTools gcs = new GcsObjectTools(storage, System.getenv("AGENT_BUCKET"));

LlmAgent agent = LlmAgent.builder()
    .name("report_assistant")
    .model("gemini-2.5-flash")
    .instruction("You manage the user's report files. Read a file before replacing it.")
    .tools(
        FunctionTool.create(gcs, "listFiles"),
        FunctionTool.create(gcs, "readText"),
        FunctionTool.create(gcs, "writeText"),
        FunctionTool.create(gcs, "shareLink"),
        FunctionTool.create(gcs, "deleteFile", true))   // a person confirms deletes
    .build();

Compile with -parameters or annotate every parameter, as here, so the schema the model sees has the right names; writing a FunctionTool explains how the schema is derived.

Generation preconditions

Every object in GCS has a generation, a number that changes on each write. Conditional requests compare it on the server: doesNotExist() sends ifGenerationMatch=0 and succeeds only if no live object has that name; generationMatch(g) succeeds only if the live generation is still g. A mismatch returns HTTP 412, which the tool turns into STALE_WRITE.

This gives the agent optimistic concurrency for free. Two agent sessions, or an agent and a person editing the same file, can no longer clobber each other: the second writer is told to re-read. Preconditions also decide retries. The Java client's default retry strategy treats an upload as safe to retry only when a generation precondition is set; without one, a transient failure surfaces to your code, and an application-level retry risks overwriting newer data. A retried conditional write either succeeds once or fails with 412.

Signed URLs instead of bytes

When a user needs the file itself, do not stream it through the model. Return a V4 signed URL: a link that grants one HTTP method on one object until an expiry, signed by the service account. The longest allowed expiry is 604,800 seconds (seven days); for an agent handing a link to the person in the conversation, fifteen minutes is plenty.

@Schema(description = "Create a short-lived download link for one of the user's files.")
public Map<String, Object> shareLink(
    @Schema(name = "name", description = "Relative file name") String name,
    @Schema(name = "toolContext") ToolContext toolContext) {
  BlobInfo info = BlobInfo.newBuilder(bucket, Namespace.objectName(toolContext, name)).build();
  if (storage.get(info.getBlobId()) == null) return Map.of("error", "NOT_FOUND");
  URL url = storage.signUrl(info, 15, TimeUnit.MINUTES,
      Storage.SignUrlOption.withV4Signature());
  return Map.of("url", url.toString(), "expiresInMinutes", 15);
}

On Cloud Run or GKE the service account has no private key file. The client then signs through the IAM Credentials signBlob API, so the service account needs iam.serviceAccounts.signBlob on itself, usually granted with the Service Account Token Creator role; without it, signing fails at runtime, not at startup. For uploads, sign an HttpMethod.PUT URL with SignUrlOption.httpMethod(HttpMethod.PUT) and pin the content type with SignUrlOption.withExtHeaders(...), so the browser uploads directly to the user's prefix without the bytes passing through your service.

A signed URL is a bearer credential. Anyone holding it can use it until it expires, and agent transcripts are logged. Redact URLs in logs and traces, or keep them out of the model's text entirely by putting them in session state for the UI to render.

Bucket settings that back up the code

The bucket's configuration is the second line of defence when the tool code has a bug.

SettingRecommendationWhy
Access controluniform bucket-level access; no ACLsone IAM policy to audit
Agent identitydedicated service account with Storage Object User on this bucket onlya prompt injection cannot reach other buckets
Per-tenant limitsoptional IAM conditions on resource.name prefixserver-side enforcement of the namespace rule
Encryptiondefault Google-managed keys, or CMEK when policy demands itwith CMEK, the GCS service agent needs encrypt/decrypt on the key
Retentionkeep scratch under a top-level tmp/users/{uid}/ and delete with a matchesPrefix: tmp/ age rulelifecycle prefixes are literal (no wildcards); scratch files otherwise grow forever
Recoverycheck the soft-delete and versioning policydeleted or overwritten objects can be restored
Exposurepublic access prevention enforcedno object becomes public by accident

Deploy the agent with that service account, as in deploying ADK Java to Cloud Run, and never ship a key file in the container.

Worked example: a conflicting edit

A user asks: "Update the Q3 summary with the new churn number and send me a link." The agent calls listFiles("reports/") and sees q3_summary.md at generation 1718. It calls readText, receives 9 KB of text and generation 1718, edits the text, and calls writeText with expected generation 1718. Meanwhile a colleague has saved a correction, so the live generation is 1722 and the write returns STALE_WRITE. The agent re-reads, merges the colleague's change, writes against 1722 and receives 1731. Finally shareLink returns a fifteen-minute URL. Five tool calls; the model saw 18 KB of text and never touched another user's prefix.

Testing

Unit-test the tools against LocalStorageHelper from the google-cloud-nio testing package, which provides an in-memory Storage. It is convenient for the namespace guard and for list and read logic; it does not reproduce IAM or signed-URL serving, and you should not rely on it for precondition semantics. Run a small integration suite against a real test bucket in CI for those: a stale write must return STALE_WRITE; a name containing .. must return INVALID_NAME; a second user's session must not list the first user's files; and a signed URL must stop working after expiry.

Failure modes

  • Model-chosen prefixes. Passing a bucket or full object name from the model invites cross-user access.
  • Bytes in context. Returning a 5 MB CSV as text fills the window and multiplies cost on every later turn.
  • Unconditional writes. Lost updates between sessions, and retries that overwrite newer data.
  • Leaked signed URLs. Logged links are live credentials until expiry; keep expiries short and redact.
  • Missing signBlob permission. Signing works locally with a key file and fails on Cloud Run.
  • Per-call clients. Building a Storage client per tool call wastes connections and latency.
  • Unbounded growth. No lifecycle rule, so scratch files accumulate cost and risk.

Trade-offs

Custom object tools versus the artifact service is the main choice. Use artifacts when files belong to a conversation and versioning by the framework is enough. Use object tools when files outlive sessions, are shared with people or other systems, or must sit in a layout someone else defined. Many agents use both. Text-only tools keep the model's view simple; for binary files, return metadata and a signed URL, and let a code-execution or data tool process the bytes outside the context window. For broader Google Cloud tooling such as BigQuery and Vertex AI retrieval, see ADK Java Google Cloud tools.

What to do next

  1. Decide which files are artifacts and which are user-visible objects, and document the split.
  2. Create a dedicated bucket and service account; enforce uniform access and public access prevention.
  3. Implement the namespace guard with a per-user prefix from toolContext.userId().
  4. Add list, read, write, share and confirmed delete tools with size limits and error codes.
  5. Use doesNotExist() and generationMatch() on every write and delete.
  6. Grant signBlob on the service account and keep signed-URL expiry short; redact URLs in logs.
  7. Add lifecycle rules, and CMEK if policy requires it.
  8. Write integration tests for stale writes, invalid names, cross-user isolation and URL expiry.
Key takeaway: Treat Cloud Storage tools as a security boundary: the code, not the model, chooses the bucket and a per-user prefix; tools return metadata, bounded text and short-lived signed URLs rather than bytes; every write carries a generation precondition; and a narrowly scoped service account, uniform access, lifecycle rules and optional CMEK back up the code when it is wrong.