Attachments

This guide covers proxying attachment operations — upload, download, signed URLs, and deletion — from your agent app to the Memory Service.

New to attachment concepts? Read Attachments first to understand the attachment lifecycle, external URL references vs server-stored files, and how attachments link to conversation entries. This guide focuses on the Quarkus implementation.

Prerequisites

Starting checkpoint: This guide starts from java/quarkus/examples/doc-checkpoints/03-with-history

Make sure you’ve completed the previous guides:

Why Proxy Attachments?

Agent apps mediate all interactions between end users and the Memory Service. Your frontend cannot call the Memory Service directly — it sends requests to your agent app, which forwards them with proper authentication.

The MemoryServiceProxy helper already handles the complexity of multipart uploads, binary downloads, redirect handling, and bearer token propagation for attachments. You just need to create thin JAX-RS resources that delegate to it:

  • AttachmentsProxyResource — Authenticated proxy for upload, download, download-url, and delete
  • AttachmentDownloadProxyResource — Unauthenticated proxy for signed download URLs (used by browser <img>, <audio>, <video> tags)

Add Attachment Proxy Endpoints

Create AttachmentsProxyResource.java to proxy authenticated attachment operations to the Memory Service:

AttachmentsProxyResource.java
package org.acme;

import io.github.chirino.memory.runtime.MemoryServiceProxy;
import io.smallrye.common.annotation.Blocking;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.jboss.resteasy.reactive.server.multipart.MultipartFormDataInput;

/**
 * JAX-RS resource that proxies attachment requests to the memory-service.
 * Delegates all logic to {@link MemoryServiceProxy}.
 */
@Path("/v1/attachments")
@ApplicationScoped
@Blocking
public class AttachmentsProxyResource {

    @Inject MemoryServiceProxy proxy;

    @POST
    @Consumes(MediaType.MULTIPART_FORM_DATA)
    @Produces(MediaType.APPLICATION_JSON)
    public Response upload(
            MultipartFormDataInput input, @QueryParam("expiresIn") String expiresIn) {
        return proxy.uploadAttachment(input, expiresIn);
    }

    @GET
    @Path("/{id}")
    public Response retrieve(@PathParam("id") String id) {
        return proxy.retrieveAttachment(id);
    }

    @GET
    @Path("/{id}/download-url")
    @Produces(MediaType.APPLICATION_JSON)
    public Response getDownloadUrl(@PathParam("id") String id) {
        return proxy.getAttachmentDownloadUrl(id);
    }

    @DELETE
    @Path("/{id}")
    public Response delete(@PathParam("id") String id) {
        return proxy.deleteAttachment(id);
    }
}

Each method is a one-liner delegation to MemoryServiceProxy, which handles:

  • Streaming upload — Forwards multipart file uploads without buffering the entire file in memory.
  • Bearer token propagation — Extracts the user’s bearer token and forwards it to the Memory Service for authorization.
  • Redirect handling — The retrieveAttachment() method handles 302 redirects for S3 presigned URLs.
  • Response forwarding — Content-Type, Content-Length, and Content-Disposition headers are forwarded from the Memory Service response.

Add Signed Download Proxy

Browser elements like <img>, <audio>, and <video> tags cannot send Authorization headers. The Memory Service solves this with signed download URLs that embed a time-limited token in the URL path. This proxy forwards those unauthenticated requests.

Create AttachmentDownloadProxyResource.java:

AttachmentDownloadProxyResource.java
package org.acme;

import io.github.chirino.memory.runtime.MemoryServiceProxy;
import io.smallrye.common.annotation.Blocking;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.core.Response;

/**
 * Unauthenticated proxy that forwards signed download requests to the memory-service. No bearer
 * token is required — the signed token in the URL path provides authorization.
 */
@Path("/v1/attachments/download")
@ApplicationScoped
@Blocking
public class AttachmentDownloadProxyResource {

    @Inject MemoryServiceProxy proxy;

    @GET
    @Path("/{token}/{filename}")
    public Response download(
            @PathParam("token") String token, @PathParam("filename") String filename) {
        return proxy.downloadAttachmentByToken(token, filename);
    }
}

This endpoint requires no bearer token — the signed token in the URL path provides authorization.

Update Security Configuration

Update application.properties to allow unauthenticated access to the signed download path while requiring authentication for all other /v1/* endpoints:

application.properties
# Signed download URLs are unauthenticated (token in URL provides authorization)
quarkus.http.auth.permission.attachment-download.paths=/v1/attachments/download/*
quarkus.http.auth.permission.attachment-download.policy=permit

The attachment-download permission must allow unauthenticated access to /v1/attachments/download/* since signed URLs are meant to be used without an Authorization header. The api permission requires authentication on all other /v1/* endpoints. Quarkus matches the most specific path first, so the order in the properties file doesn’t matter.

Testing

Make sure you define the bearer token helper:

function get-token() {
  curl -sSfX POST http://localhost:8081/realms/memory-service/protocol/openid-connect/token \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d "client_id=memory-service-client" \
    -d "client_secret=change-me" \
    -d "grant_type=password" \
    -d "username=bob" \
    -d "password=bob" \
    | jq -r '.access_token'
}

Upload an Attachment

echo "Hello, attachments!" > /tmp/test-upload.txt
curl -sSfX POST http://localhost:9090/v1/attachments \
  -H "Authorization: Bearer $(get-token)" \
  -F "file=@/tmp/test-upload.txt" | jq

Example output:

{
  "id": "2a44d326-b032-4221-a363-4945fa71731d",
  "href": "/v1/attachments/2a44d326-b032-4221-a363-4945fa71731d",
  "contentType": "text/plain",
  "filename": "test-upload.txt",
  "size": 21,
  "sha256": "a1b2c3d4...",
  "expiresAt": "2025-01-28T11:30:00Z",
  "status": "ready"
}

Save the attachment ID for subsequent commands:

ATTACHMENT_ID="<id from the response above>"

Get a Signed Download URL

curl -sSfX GET "http://localhost:9090/v1/attachments/${ATTACHMENT_ID}/download-url" \
  -H "Authorization: Bearer $(get-token)" | jq

Example output:

{
  "contentType": "text/plain",
  "expiresAt": "2026-03-06T15:59:00Z",
  "filename": "test-upload.txt",
  "href": "/v1/attachments/315e31ba-805b-4508-88ab-f197b0050030",
  "id": "315e31ba-805b-4508-88ab-f197b0050030",
  "sha256": "45756124416f43c4c9c5138fb2909cea45ae20b9852e9def468370906be6c84a",
  "size": 20,
  "status": "ready"
}

Download via Signed URL (No Auth Required)

Use the url from the previous response — no Authorization header needed:

curl -sSf "http://localhost:9090${DOWNLOAD_URL}"

Example output:

{
  "expiresIn": 300,
  "url": "/v1/attachments/download/MTcwNDd8MTc3MjgwOTQ0MA.iqQWNgAkLznmmHRC49o14ixpZfvrE_Xl-1yWSRmpqKA/test-upload.txt"
}

Delete an Attachment

curl -sSfX DELETE "http://localhost:9090/v1/attachments/${ATTACHMENT_ID}" \
  -H "Authorization: Bearer $(get-token)"

Next Steps