An agent built with the Agent Development Kit for Java moves text through more places than a plain chat endpoint does. The user's message is stored as a session event. The model provider receives a request assembled from that history. The model's reply becomes another event. Tool calls send arguments to external systems and bring results back into the context. Logs, traces and long-term memory copy some of all of this again. Any personal data in the conversation can land in every one of those places.
Redaction is therefore a question of where before it is a question of how. ADK Java gives you a set of interception points, and they differ in one way that matters more than any other: whether they run before or after the data is persisted. This article maps the flows, checks each hook against the framework's source, builds a redaction plugin, adds a managed detector for the cases regular expressions cannot handle, and ends with operations and a checklist. The general theory of PII detection, its accuracy and its evaluation lives in PII detection and redaction architecture; here we apply it inside ADK Java.
Where personal data travels in an ADK Java app
Follow one request. A Runner receives a user Content through runAsync. It turns the message into an event and appends it to the session through the configured session service, which may be in memory, a database or a managed service. The agent then builds an LlmRequest from the session's events plus its instructions and sends it to the model. The response comes back as an LlmResponse, becomes an event, and is appended too. If the model asks for a tool, the arguments go to the tool, the result returns as a function response, and that also becomes an event. Each appended event is then yielded to your caller, which may log it or stream it to a browser.
That gives six sinks: the session store, the model provider, tool back ends, the caller and its logs, state values written into the session, and any memory service fed from finished sessions. A redaction design must say, for each sink, which hook cleans the data before it arrives. The details of sessions and state are in session context in ADK Java.
The hooks, checked against the source
ADK Java has two layers of interception. Agent callbacks are set on one LlmAgent through its builder, for example beforeModelCallback(...) and afterToolCallback(...), each with a synchronous variant and a list form. Plugins implement the Plugin interface, usually by extending BasePlugin, and are registered once on the runner with Runner.builder().plugins(...), so they apply to every agent in the app. For a cross-cutting control such as redaction, a plugin is the right layer: nobody can forget to attach it to a new sub-agent. The callback model in general is covered in ADK Java callbacks.
| Plugin method | Runs | What a non-empty return does | Use for PII |
|---|---|---|---|
onUserMessageCallback(InvocationContext, Content) | Before the user message is turned into an event | Replaces the message; the replacement is what gets stored | Primary inbound redaction |
beforeModelCallback(CallbackContext, LlmRequest.Builder) | Before each model call | Returns a response and skips the model | A last gate: block, do not rewrite |
afterModelCallback(CallbackContext, LlmResponse) | After the model answers, before the event is stored | Replaces the response | Redact model output |
beforeToolCallback(BaseTool, Map, ToolContext) | Before a tool runs | Becomes the tool's result; the tool is skipped | Deny calls whose arguments carry PII |
afterToolCallback(BaseTool, Map, ToolContext, Map) | After a tool returns | Replaces the result map | Redact tool results |
onEventCallback(InvocationContext, Event) | After the event is appended | Replaces what is yielded to the caller | Not a storage control |
Two consequences follow from the table. First, a non-empty return from beforeToolCallback short-circuits the tool rather than rewriting its arguments. To keep PII out of a tool, deny the call and tell the model why, rather than assuming you can edit the argument map in place. Second, beforeModelCallback receives an LlmRequest.Builder that, in the version we checked, offers setters but no getter for the contents. Do not build your design on rewriting the request there. Clean the history at the points where it is created instead, and treat the before-model hook as a gate.
The persistence trap
The most important fact in this article is an ordering detail in the runner. When a step produces an event, the runner appends it to the session service first and only then passes the stored event to the plugins' onEventCallback. The callback's return value changes what your caller receives. It does not change what was written.
So a team that implements redaction in onEventCallback, because it is the one place that sees every event, gets a convincing demo: the browser and the application logs show [EMAIL_ADDRESS]. Meanwhile the session table, which may be replicated, backed up and retained for months, holds every raw value. Worse, the next turn's model request is built from the stored history, so the provider still receives the raw data on every later call.
The inbound side has the opposite property. The runner calls onUserMessageCallback first and builds the user event from whatever it returns, so a replacement message is what is stored and what the model sees on every later turn. That makes it the one hook that protects every downstream sink at once, and the place to put your strongest detector.
Choosing a redaction form
Deleting a value outright often breaks the conversation: a request to update the address on an order is meaningless once the address is gone. There are three common replacements. A type label such as [EMAIL_ADDRESS] keeps the sentence readable and loses identity. A stable token such as <EMAIL_1> lets the model see that two mentions refer to the same thing. A reversible pseudonym keeps the mapping from token to value in a separate vault so that a trusted tool can restore it when it really needs the value.
For an agent, stable tokens are usually the best default: cheap, understood by the model, and resolvable server-side by a tool entitled to the real value. Keep the vault out of session state, which is persisted next to the tokens and would undo the redaction.
A redaction plugin
The plugin below uses the verified signatures. The detector behind it is an interface so you can start with patterns and add a managed service later. Text parts are redacted; other parts pass through unchanged, which is a gap the failure-mode section returns to.
public final class PiiRedactionPlugin extends BasePlugin {
private final Redactor redactor;
private final Set<String> piiTools; // tools allowed to receive PII
public PiiRedactionPlugin(Redactor redactor, Set<String> piiTools) {
super("pii_redaction");
this.redactor = redactor;
this.piiTools = piiTools;
}
@Override
public Maybe<Content> onUserMessageCallback(InvocationContext ctx, Content msg) {
Content clean = redactContent(msg);
return clean.equals(msg) ? Maybe.empty() : Maybe.just(clean);
}
@Override
public Maybe<LlmResponse> afterModelCallback(CallbackContext ctx, LlmResponse resp) {
if (resp.content().isEmpty()) return Maybe.empty();
Content original = resp.content().get();
Content clean = redactContent(original);
return clean.equals(original)
? Maybe.empty()
: Maybe.just(resp.toBuilder().content(clean).build());
}
@Override
public Maybe<Map<String, Object>> beforeToolCallback(
BaseTool tool, Map<String, Object> args, ToolContext toolContext) {
if (piiTools.contains(tool.name())) return Maybe.empty();
List<String> found = redactor.findTypes(String.valueOf(args));
if (found.isEmpty()) return Maybe.empty();
return Maybe.just(Map.of(
"status", "blocked",
"reason", "arguments contained " + found + "; use the record id instead"));
}
@Override
public Maybe<Map<String, Object>> afterToolCallback(
BaseTool tool, Map<String, Object> args, ToolContext toolContext,
Map<String, Object> result) {
Map<String, Object> clean = redactValue(result);
return clean.equals(result) ? Maybe.empty() : Maybe.just(clean);
}
private Content redactContent(Content content) {
List<Part> out = new ArrayList<>();
for (Part part : content.parts().orElse(List.of())) {
out.add(part.text()
.map(t -> part.toBuilder().text(redactor.redact(t)).build())
.orElse(part));
}
return content.toBuilder().parts(out).build();
}
@SuppressWarnings("unchecked")
private Map<String, Object> redactValue(Map<String, Object> map) {
Map<String, Object> out = new LinkedHashMap<>();
map.forEach((k, v) -> out.put(k, redactAny(v)));
return out;
}
private Object redactAny(Object v) {
if (v instanceof String s) return redactor.redact(s);
if (v instanceof Map<?, ?> m) return redactValue((Map<String, Object>) m);
if (v instanceof List<?> l) return l.stream().map(this::redactAny).toList();
return v;
}
}Wire it once: Runner.builder().agent(rootAgent).appName("support").sessionService(sessions).plugins(new PiiRedactionPlugin(redactor, Set.of("crm_lookup"))).build(). Because it is registered on the runner, sub-agents and agents added later inherit it.
The detector: patterns first, validated
Structured identifiers such as email addresses, card numbers and phone numbers are best handled locally with patterns, because that is fast, free and deterministic. Patterns alone produce false positives on any long digit string, so validate where a checksum exists. Card numbers carry a Luhn check digit, and requiring it removes most order numbers and timestamps from the matches.
public interface Redactor {
String redact(String text);
List<String> findTypes(String text);
}
public final class PatternRedactor implements Redactor {
private static final Pattern EMAIL =
Pattern.compile("[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}");
private static final Pattern CARD = Pattern.compile("\\b(?:\\d[ -]?){12,18}\\d\\b");
@Override
public String redact(String text) {
String out = EMAIL.matcher(text).replaceAll("[EMAIL_ADDRESS]");
Matcher m = CARD.matcher(out);
StringBuilder sb = new StringBuilder();
while (m.find()) {
String digits = m.group().replaceAll("[ -]", "");
m.appendReplacement(sb, luhn(digits) ? "[CARD_NUMBER]" : m.group());
}
m.appendTail(sb);
return sb.toString();
}
@Override
public List<String> findTypes(String text) {
List<String> types = new ArrayList<>();
if (EMAIL.matcher(text).find()) types.add("EMAIL_ADDRESS");
Matcher m = CARD.matcher(text);
while (m.find()) {
if (luhn(m.group().replaceAll("[ -]", ""))) { types.add("CARD_NUMBER"); break; }
}
return types;
}
static boolean luhn(String digits) {
int sum = 0;
boolean dbl = false;
for (int i = digits.length() - 1; i >= 0; i--) {
int d = digits.charAt(i) - '0';
if (dbl) { d *= 2; if (d > 9) d -= 9; }
sum += d;
dbl = !dbl;
}
return sum % 10 == 0;
}
}Names, street addresses and free-text medical details cannot be matched reliably with patterns. For those, call a classifier. On Google Cloud, Sensitive Data Protection (the DLP API) inspects text for built-in information types and can de-identify it in the same call. The general case for data loss prevention controls is made in data loss prevention.
String deidentify(DlpServiceClient dlp, String projectId, String text) {
InspectConfig inspect = InspectConfig.newBuilder()
.addInfoTypes(InfoType.newBuilder().setName("PERSON_NAME"))
.addInfoTypes(InfoType.newBuilder().setName("STREET_ADDRESS"))
.addInfoTypes(InfoType.newBuilder().setName("PHONE_NUMBER"))
.setMinLikelihood(Likelihood.POSSIBLE)
.build();
DeidentifyConfig deid = DeidentifyConfig.newBuilder()
.setInfoTypeTransformations(InfoTypeTransformations.newBuilder()
.addTransformations(InfoTypeTransformations.InfoTypeTransformation.newBuilder()
.setPrimitiveTransformation(PrimitiveTransformation.newBuilder()
.setReplaceWithInfoTypeConfig(ReplaceWithInfoTypeConfig.getDefaultInstance()))))
.build();
DeidentifyContentRequest req = DeidentifyContentRequest.newBuilder()
.setParent(LocationName.of(projectId, "global").toString())
.setInspectConfig(inspect)
.setDeidentifyConfig(deid)
.setItem(ContentItem.newBuilder().setValue(text))
.build();
return dlp.deidentifyContent(req).getItem().getValue();
}Run the local patterns first and the remote call second, on the already-masked text, so structured values never leave your process. Create one DlpServiceClient per process rather than per message; it holds a channel and is meant to be reused.
A worked trace
A user writes: Hi, I'm Priya Shah, card 4111 1111 1111 1111 was charged twice, email priya@example.com. The plugin's onUserMessageCallback runs the pattern redactor, which replaces the email and, because the digits pass the Luhn check, the card number. The managed detector then replaces the name. The stored user event reads Hi, I'm [PERSON_NAME], card [CARD_NUMBER] was charged twice, email [EMAIL_ADDRESS].
The model sees only that text and asks to call refund_lookup with {"email": "[EMAIL_ADDRESS]"}. That argument holds a label, not an address, so beforeToolCallback finds nothing and the tool runs. The tool in turn should look the customer up by the authenticated session's user id, not by an email address supplied through the model. Its result contains a billing address; afterToolCallback replaces it before the function response is stored. The model's final answer quotes the last four digits of the card from the tool result; the card pattern needs at least thirteen digits, so four pass through, which is the intended behaviour.
Failure modes
- Redacting in onEventCallback only. Raw data is persisted and sent to the model on later turns. Use it only for caller-facing shaping.
- Non-text parts. Images, files and audio pass through a text redactor untouched. Reject them or route them through a detector that handles them.
- Function calls in model output. A model can put a value it saw in a tool result into a later tool's arguments. That is why argument checking happens in
beforeToolCallbackand not only on text. - State and instructions. Values written to session state, or templated into instructions, bypass the message hooks. Redact before writing state.
- Streaming. If you stream partial responses, test whether a value split across chunks is caught; a per-chunk pattern cannot see across the boundary.
- Failing open. If the managed detector times out, decide deliberately. For regulated data, block the turn rather than store it raw.
- Over-redaction. Aggressive name detection replaces product names and places, and the agent becomes useless. Measure precision as well as recall.
Operating it
Treat the redactor as a model with an evaluation set. Collect a labelled set of realistic messages and tool results, measure recall per information type, and fail the build if recall drops. Count redactions per type per day as a metric; a sudden fall usually means a format changed, not that users stopped sharing data. Log the types found and the hook that found them, never the values. Run a periodic scan of the session store with the same detector to prove that nothing raw is accumulating, and keep that scan's result as audit evidence. Apply the rules on agent safety in ADK Java safety to the plugin itself: it is security code, so changes go through review and tests.
The trade-off is latency against coverage: local patterns are nearly free, while a managed call adds a network round trip, so run it on input and tool results, not on every streamed chunk.
What to do next
- Draw your sinks: session service, model provider, each tool, logs, state and memory. Mark which hook protects each one.
- Register a redaction plugin on the runner with
onUserMessageCallback,afterModelCallbackandafterToolCallback. - Add
beforeToolCallbackdenial for tools that must not receive PII, and change those tools to look records up by authenticated id. - Remove any redaction that lives only in
onEventCallbackor in the caller. - Start with validated patterns, then add a managed detector for names and addresses, with an explicit fail-closed rule.
- Build a labelled evaluation set, track recall per type, and scan the session store on a schedule.