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 Spring implementation.
Prerequisites
Starting checkpoint: This guide starts from java/spring/examples/doc-checkpoints/03-with-history
Make sure you’ve completed the previous guides:
- Getting Started - Basic memory service integration
- Conversation History - History recording and APIs
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 controllers that delegate to it:
AttachmentsProxyController— Authenticated proxy for upload, download, download-url, and deleteAttachmentDownloadProxyController— Unauthenticated proxy for signed download URLs (used by browser<img>,<audio>,<video>tags)
Add Attachment Proxy Endpoints
Create AttachmentsProxyController.java to proxy authenticated attachment operations to the Memory Service:
package com.example.demo;
import io.github.chirino.memoryservice.client.MemoryServiceProxy;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* Spring REST controller that proxies attachment requests to the memory-service.
* Delegates all logic to {@link MemoryServiceProxy}.
*/
@RestController
@RequestMapping("/v1/attachments")
class AttachmentsProxyController {
private final MemoryServiceProxy proxy;
AttachmentsProxyController(MemoryServiceProxy proxy) {
this.proxy = proxy;
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<?> upload(
@RequestPart("file") MultipartFile file,
@RequestParam(value = "expiresIn", required = false) String expiresIn) {
return proxy.uploadAttachment(file, expiresIn);
}
@GetMapping("/{id}")
public ResponseEntity<?> retrieve(@PathVariable String id) {
return proxy.retrieveAttachment(id);
}
@GetMapping("/{id}/download-url")
public ResponseEntity<?> getDownloadUrl(@PathVariable String id) {
return proxy.getAttachmentDownloadUrl(id);
}
@DeleteMapping("/{id}")
public ResponseEntity<?> delete(@PathVariable String id) {
return proxy.deleteAttachment(id);
}
} Each method is a one-liner delegation to MemoryServiceProxy, which handles:
- WebClient streaming — Uses Spring’s
WebClientwithexchangeToMonofor full control over the response, avoiding buffering large files 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. - Multipart upload — Uses
BodyInserters.fromMultipartData()with anInputStreamResourceto stream the file through without loading it entirely into memory.
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 AttachmentDownloadProxyController.java:
package com.example.demo;
import io.github.chirino.memoryservice.client.MemoryServiceProxy;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Unauthenticated proxy that forwards signed download requests to the memory-service. The signed
* token in the URL path provides authorization — no bearer token is required.
*/
@RestController
@RequestMapping("/v1/attachments/download")
class AttachmentDownloadProxyController {
private final MemoryServiceProxy proxy;
AttachmentDownloadProxyController(MemoryServiceProxy proxy) {
this.proxy = proxy;
}
@GetMapping("/{token}/{filename}")
public ResponseEntity<?> download(@PathVariable String token, @PathVariable 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 SecurityConfig.java to allow unauthenticated access to the signed download path:
package com.example.demo;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(
auth ->
auth.requestMatchers("/ready")
.permitAll()
// Signed download URLs are unauthenticated
// (token in URL provides authorization)
.requestMatchers("/v1/attachments/download/**")
.permitAll()
.anyRequest()
.authenticated())
.oauth2Login(oauth2 -> oauth2.defaultSuccessUrl("/", false))
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> {}))
.logout(logout -> logout.logoutSuccessUrl("/"));
return http.build();
}
} The key change is adding .requestMatchers("/v1/attachments/download/**").permitAll() before .anyRequest().authenticated(). This allows signed download URLs to work without an Authorization header, while all other endpoints still require authentication.
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": "96618a21-c7fc-41c6-ba71-b1d83e3ecb95",
"href": "/v1/attachments/96618a21-c7fc-41c6-ba71-b1d83e3ecb95",
"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:24Z",
"filename": "test-upload.txt",
"href": "/v1/attachments/a06af9bd-43a3-47a6-bbc5-d46aae03e36c",
"id": "a06af9bd-43a3-47a6-bbc5-d46aae03e36c",
"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/MTcwNTV8MTc3MjgwOTQ2NA.DUpxA9YbqkQBTz943OIRA9V5zfLoT0t2Z7rX0DcGj3U/test-upload.txt"
} Delete an Attachment
curl -sSfX DELETE "http://localhost:9090/v1/attachments/${ATTACHMENT_ID}" \
-H "Authorization: Bearer $(get-token)" Next Steps
- Conversation Forking — Branch conversations to explore alternative paths
- Response Recording and Resumption — Streaming responses with resume and cancel support
- Conversation Sharing — Share conversations with other users