An ADK agent's session stores events, not messages. A single user question can produce a model reply, several tool calls, their results, state updates and streamed fragments, all as events. That is the right shape for durability and replay, and the Postgres event log article shows how to persist it with one transaction per appended event. But the people and programs that read a conversation want something else: a chat UI wants the last fifty human-readable messages, the context assembler wants the newest turns that fit a token budget, and a support engineer wants to search last week's conversations for an error string.
This article builds that second view: a message history table derived from the event log, kept in step with it, and designed for those three readers. A status note first: at the time of writing, ADK Java's core ships InMemorySessionService and VertexAiSessionService, and its contrib directory adds a Firestore session service. We found no official Postgres implementation for Java, so "adk-postgres" here means a service you write yourself against the documented BaseSessionService interface. Table and class names are ours.
Events versus messages
The distinction drives every design choice below. An event is a storage record: it has an id, an invocation id, an author, an optional Content, an EventActions object carrying state deltas and other side effects, and a timestamp. A message is a presentation unit: a role, some text, perhaps a tool name. Many events are not messages at all (a state-only update has no content), and some events are messages only for some readers (a tool result belongs in the model's context but usually not in the end user's chat window).
So treat the event log as the source of truth and the message table as a projection: a read model computed from events, which can be dropped and rebuilt at any time. This lets you change presentation, add columns or fix a projection bug without touching the log of what actually happened.
Which events become messages
The projection is a function from one event to zero or one rows. The rules, using accessors that exist on ADK Java's Event class:
| Event shape | How to detect it | Message row |
|---|---|---|
| Streamed fragment | partial() is true | None. The default appendEvent does not add partial events to the session either; the final event carries the full content. |
| Compaction | actions().compaction() is present | One summary row holding compactedContent() text and the covered startTimestamp() to endTimestamp() range. |
| Tool call | functionCalls() is non-empty | One tool_call row with the function names and a short argument preview. |
| Tool result | functionResponses() is non-empty | One tool_result row with names and a truncated result preview. |
| User or model text | content() has text parts | user if author() is "user", otherwise model. |
| State-only update | No content, no calls | None. |
Two details are easy to miss. Model thinking arrives as parts flagged with thought(); excluding them from the text keeps internal reasoning out of UIs and search indexes. And timestamp() on an ADK Java event is epoch milliseconds (the builder fills it from Instant.now().toEpochMilli()), which is why the schema names the column with an _ms suffix. How and when ADK Java decides to emit compaction events is a framework concern we have not documented here; the projection only needs to recognise one when it arrives.
The schema
The message table reuses the event log's key, (app_name, user_id, session_id, seq), so every message points at exactly one event and a cascade delete from the log removes it. The session-scoped primary key is also the index for both UI paging and context windows, which are range scans over one session.
-- Read model built on the adk_events table from the event log article.
CREATE TABLE adk_messages (
app_name text NOT NULL,
user_id text NOT NULL,
session_id text NOT NULL,
seq bigint NOT NULL, -- same seq as the source event
invocation_id text, -- groups one user turn and everything it caused
kind text NOT NULL, -- user | model | tool_call | tool_result | summary
author text NOT NULL, -- Event.author(): "user" or an agent name
text text, -- concatenated text parts, thoughts excluded
tool_name text, -- for tool_call / tool_result rows
covers_from_ms bigint, -- summary rows: compacted time range
covers_to_ms bigint,
token_est integer NOT NULL, -- estimate at projection time
event_ts_ms bigint NOT NULL, -- Event.timestamp(), epoch milliseconds
redacted boolean NOT NULL DEFAULT false,
search tsvector GENERATED ALWAYS AS (to_tsvector('simple', coalesce(text, ''))) STORED,
PRIMARY KEY (app_name, user_id, session_id, seq),
FOREIGN KEY (app_name, user_id, session_id, seq)
REFERENCES adk_events (app_name, user_id, session_id, seq) ON DELETE CASCADE
);
CREATE INDEX adk_messages_search ON adk_messages USING gin (search);token_est estimates the full event as the model will receive it, not the stored preview, so a 200 KB tool result counts at its real size in the context budget; compute it once at write time with your model's tokenizer or a conservative characters-divided-by-four rule. The generated tsvector column uses the simple configuration so identifiers, error codes and non-English text are indexed as written rather than stemmed. Store previews, not full tool payloads; the full payload stays in the event log.
Writing the projection
There are two places to run the projector. Synchronously, inside the same transaction that inserts the event, so a committed event always has its message row. Asynchronously, in a worker that reads events after a stored watermark seq and writes messages in batches. Start synchronous. The projection is cheap, one row per event, and it removes a whole class of "the UI does not show my last message yet" bugs. Move to asynchronous only if projection logic becomes expensive, for example if you add embeddings, and then expose the watermark so readers know how far behind they are.
// Called inside the appendEvent transaction, after insertEvent(c, s, e, seq).
static Optional<MessageRow> project(Session s, Event e, long seq) {
if (e.partial().orElse(false)) return Optional.empty(); // fragments are never stored
Optional<EventCompaction> comp = e.actions().compaction();
if (comp.isPresent()) {
EventCompaction ec = comp.get();
return Optional.of(MessageRow.summary(s, e, seq, textOf(ec.compactedContent()),
ec.startTimestamp(), ec.endTimestamp()));
}
if (!e.functionCalls().isEmpty()) {
String names = e.functionCalls().stream()
.map(fc -> fc.name().orElse("?")).collect(Collectors.joining(","));
return Optional.of(MessageRow.of(s, e, seq, "tool_call", names, argsPreview(e)));
}
if (!e.functionResponses().isEmpty()) {
String names = e.functionResponses().stream()
.map(fr -> fr.name().orElse("?")).collect(Collectors.joining(","));
return Optional.of(MessageRow.of(s, e, seq, "tool_result", names, resultPreview(e)));
}
String text = e.content().map(MessageProjector::textOf).orElse("");
if (text.isBlank()) return Optional.empty(); // e.g. a pure state update
String kind = "user".equals(e.author()) ? "user" : "model";
return Optional.of(MessageRow.of(s, e, seq, kind, null, text));
}
static String textOf(Content c) {
return c.parts().orElse(List.of()).stream()
.filter(p -> !p.thought().orElse(false)) // keep model thoughts out
.map(p -> p.text().orElse(""))
.collect(Collectors.joining());
}The function reads only what the event carries. It never consults the in-memory session, so replaying the log through it produces the same rows as the live path, which is the property that makes rebuilds safe. MessageRow, argsPreview and resultPreview are your own helpers. Insert the row with ON CONFLICT DO NOTHING on the primary key so a retried append that the event log accepted as idempotent does not fail on the projection.
Reader one: the chat UI
A chat window shows the newest messages and loads older ones on scroll. Use keyset pagination on seq, never OFFSET: an offset rescans every skipped row and shifts when new messages arrive while the user is scrolling, which produces duplicated or missing messages at page boundaries.
-- UI: newest page first, then "load older" with the smallest seq the client holds.
SELECT seq, kind, author, text, tool_name, event_ts_ms
FROM adk_messages
WHERE app_name = $1 AND user_id = $2 AND session_id = $3
AND kind IN ('user', 'model', 'summary')
AND seq < $4 -- cursor; pass bigint max for the first page
ORDER BY seq DESC
LIMIT 50;Hiding tool rows is a product choice; a collapsed "used search" chip can be rendered from them with a second cheap query. The filter narrows a primary-key range, so the query stays an index range scan.
Reader two: the context window
The model does not need the whole history; it needs the most recent history that fits a token budget. GetSessionConfig offers numRecentEvents(), but a count of events is a poor proxy for tokens, and cutting at an arbitrary event can separate a tool call from its result. The model then sees a result without the call that explains it.
The fix is to choose the window by whole invocations. An invocation id groups one user turn with everything it caused, so the query below sums tokens per invocation from newest to oldest and returns the earliest sequence number whose running total still fits.
-- Context: newest turns first, keep whole invocations, stop at the token budget.
WITH turns AS (
SELECT invocation_id, min(seq) AS first_seq, sum(token_est) AS tokens
FROM adk_messages
WHERE app_name = $1 AND user_id = $2 AND session_id = $3 AND kind <> 'summary'
AND event_ts_ms > coalesce($5, 0) -- after the newest summary's covers_to_ms
GROUP BY invocation_id
), ranked AS (
SELECT *, sum(tokens) OVER (ORDER BY first_seq DESC) AS running
FROM turns
)
SELECT min(first_seq) AS window_start_seq FROM ranked WHERE running <= $4; -- $4 = budgetThe session service then loads events with seq at or after that value and returns them in order, prepended with the newest summary row if one exists. This keeps tool pairs intact, keeps the window stable across retries, and turns "how much history do we send" into a single budget number you can tune. The session context article covers how the loaded session then reaches the model.
Compaction and summaries
Long sessions eventually exceed any budget. Compaction replaces a range of old events with a summary. ADK Java represents it as an EventCompaction on an event's actions, carrying a start timestamp, an end timestamp and the compacted Content. The projection turns it into a summary row with the covered range, and the context assembler uses the newest summary plus the raw turns after its covers_to_ms.
Keep the raw events. The summary is a lossy view for the model; the log remains the record for audit, dispute resolution and future re-summarisation with a better prompt. If you need to cut storage, apply retention to whole old sessions as the event log article describes, not to events inside a live session. For facts that should survive across sessions, such as a user's preferences, use a memory service rather than ever-growing summaries.
Search, redaction and rebuilds
Full-text search over the search column with websearch_to_tsquery('simple', ...) answers support questions such as "which sessions mentioned error 4031 this week" without touching JSON. Always filter by app name, and by user id for anything user-facing; a search endpoint that forgets the tenant filter leaks one user's conversation to another. The session table article shows row-level security policies that enforce this in the database.
Redaction is where a projection earns its keep. When a user asks for a message to be removed, or a scanner finds a card number, you must change both copies: rewrite the event's payload in the log with the sensitive text replaced, then re-project that event, which sets text to the redacted version and redacted to true. Redacting only the message table leaves the data in the log, and the next rebuild brings it back.
A rebuild is: delete the session's message rows, stream its events in seq order through the projector, insert, commit. Version the projector so you know which logic built each row.
Worked example: sizing a support agent
Suppose a support agent handles 20,000 sessions a day, averaging 8 user turns per session and 4 events per turn: the user message, one tool call, its result and the model reply. That is 640,000 events and roughly 640,000 message rows a day. With about 400 bytes per row for text previews, keys and the tsvector, plus index overhead, the message table grows by very roughly 0.4 to 0.5 GB a day, far smaller than the event log if tools return documents. Measure your own averages; tool payload size dominates the log and preview size dominates the projection.
At a 90-day retention the projection holds about 58 million rows. Session-scoped primary key lookups stay fast at that size. The GIN index is the part that grows fastest and slows writes, so if search is only needed for recent conversations, index a monthly partition or keep search in a separate table populated for the last 30 days.
Failure modes
- Projection drift. A projector change applied only to new rows makes old and new sessions render differently. Version the projector and rebuild.
- Split tool pairs. Windowing by event count sends a result without its call. Window by invocation.
- Thoughts in the UI. Concatenating every text part leaks model reasoning into chat and search. Filter
thought()parts. - Partial events stored. Projecting streamed fragments multiplies rows and duplicates text. Skip
partial()events. - Redaction in one place. Editing only the message row is undone by the next rebuild.
- Missing tenant filter on search. A cross-user data leak; enforce in the database, not only in the endpoint.
What to do next
- Confirm your event log has a per-session
seqand an invocation id column; the projection depends on both. - Create
adk_messages, add the projector to yourappendEventtransaction, and backfill existing sessions with the rebuild routine. - Write a test that replays a recorded session through the projector twice and asserts identical rows.
- Switch your UI to keyset paging and your context loader to the invocation-aligned token window.
- Add redaction as a two-step operation, log first then projection, with an audit record of who redacted what.
- Enable row-level security on the message table and add a test that a search as user A never returns user B's rows.