AI Agents
An agent is only as good as the facts it is shown. This guide covers how to turn Kafka topics and database change streams into a live, consistent context object the agent reads with one query, how that compares to feature stores, vector databases and Flink, and a complete reference implementation you can run today.
Last reviewed 2026-11-08. Covers RisingWave 2.6 and later.
The Problem
Most agent failures in production are not reasoning failures. They are context failures: the agent was shown facts that were wrong by the time it acted. A support agent promises a refund on an order that shipped two minutes ago. A procurement agent reorders stock that a cancelled order just returned. A recommendation agent pushes a product the customer bought in the current session. The model did exactly what the context told it to do.
The usual fix is to give the agent more tools and let it look everything up itself. That trades one problem for three: each turn becomes slow because the tool calls run in sequence, the prompt fills with raw rows the model has to aggregate, and the calls see different moments in time, so the context can contradict itself. Teams then add a cache, a stream job to fill the cache, a vector database, and a batch job to re-embed documents. Five systems now stand between an event and the agent that needs it.
The alternative is to precompute the context. Define each context object as a query, keep the result current as events arrive, and let the agent read it in one call. That is what incrementally maintained materialized views do, and it is the reason a streaming database is a natural context layer for agents.
| Dimension | Context assembled per turn | Context maintained as views |
|---|---|---|
| Who computes the context | The agent, with 4 to 8 tool calls per turn | RisingWave, once, as events arrive |
| Latency per turn | Sum of all tool calls, often 1 to 3 seconds | One point lookup, milliseconds |
| Tokens spent on context | Raw rows from every tool call | A compact precomputed object |
| Consistency | Each tool call sees a different moment | All fields from one snapshot |
| Freshness of embeddings | Last batch embedding job | Seconds after the document arrives |
| Pieces to operate | Agent code, stream job, cache, vector DB, embedding job | One streaming database |
Architecture
The architecture has four layers, and only one of them is new. Events already flow through Kafka or come out of operational databases as change data capture. The agent loop already exists. The serving path is SQL over the PostgreSQL wire protocol or MCP, which agent frameworks already speak. The new layer is the set of materialized views in between: one per context object, each defined in SQL and maintained incrementally. In RisingWave those views, the embeddings inside them and the vector indexes over them live in one system, with state on object storage and a consistent snapshot across every view at each one-second barrier.
Five kinds of context cover most agents. Each maps to a well-understood view shape.
| Context kind | View shape | In the reference implementation |
|---|---|---|
| Entity state | One row per entity, joined from several sources, keyed by the entity ID. | customer_360: tier, lifetime value, open tickets, last order, current risk flag. |
| Recent activity | Aggregates over a sliding or hopping window, keyed by entity. | session_activity: pages viewed, cart changes and errors in the last 15 minutes. |
| Live status | Latest event per key, usually a CDC table or a deduplicated stream. | order_status_live: current fulfilment state and carrier ETA for each open order. |
| Retrievable knowledge | Append-only documents with an embedding column and a vector index. | article_embeddings: help articles and policy pages, embedded as they are published. |
| Agent memory | A table the agent writes to, with views that summarise it per entity. | agent_memory: promises made, escalations, preferences learned in earlier conversations. |
The agent never touches the raw streams. It reads the views, and because every view is updated from the same barrier, the profile, the recent activity and the open orders it sees all describe the same instant. That consistency is what tool-call assembly cannot give you, and it is what stops an agent from reasoning about a refund that already happened.
Reference Implementation
The scenario is an online shop. Orders and clickstream events arrive on Kafka, customers and support tickets live in PostgreSQL, and help articles are published to a topic. The agent answers customer questions and must know the current state of their orders, what they were just doing on the site, the latest help content, and what earlier conversations promised. Everything below runs against a single-node RisingWave; swap the connection details for RisingWave Cloud in production.
# One-node RisingWave for development. SQL port 4566, dashboard 5691.
docker run -it --pull=always -p 4566:4566 -p 5691:5691 \
risingwavelabs/risingwave:latest single_node
# Connect with any PostgreSQL client.
psql -h localhost -p 4566 -d dev -U rootKafka topics become append-only sources. The PostgreSQL tables become CDC tables through one shared CDC source, so updates to a customer's tier or a ticket's status flow through as changes rather than new rows. Watermarks on the event streams let the time windows below close correctly.
-- Append-only event streams from Kafka.
CREATE SOURCE orders (
order_id BIGINT,
customer_id BIGINT,
amount NUMERIC,
status VARCHAR, -- placed, paid, shipped, delivered, refunded
order_ts TIMESTAMPTZ,
WATERMARK FOR order_ts AS order_ts - INTERVAL '30 seconds'
) WITH (
connector = 'kafka',
topic = 'orders',
properties.bootstrap.server = 'broker:9092'
) FORMAT PLAIN ENCODE JSON;
CREATE SOURCE clickstream (
customer_id BIGINT,
event_type VARCHAR, -- page_view, add_to_cart, checkout_error
page VARCHAR,
event_ts TIMESTAMPTZ,
WATERMARK FOR event_ts AS event_ts - INTERVAL '10 seconds'
) WITH (
connector = 'kafka',
topic = 'clickstream',
properties.bootstrap.server = 'broker:9092'
) FORMAT PLAIN ENCODE JSON;
-- Help articles are published to a topic; every edit is a new version.
CREATE SOURCE help_articles (
article_id BIGINT,
version INT,
title VARCHAR,
body VARCHAR,
published_at TIMESTAMPTZ
) WITH (
connector = 'kafka',
topic = 'help_articles',
properties.bootstrap.server = 'broker:9092'
) FORMAT PLAIN ENCODE JSON;
-- Operational tables via CDC. Updates and deletes flow through.
CREATE SOURCE shop_pg WITH (
connector = 'postgres-cdc',
hostname = 'pg', port = '5432',
username = 'rw', password = '...',
database.name = 'shop', schema.name = 'public'
);
CREATE TABLE customers (
customer_id BIGINT PRIMARY KEY,
name VARCHAR,
tier VARCHAR,
region VARCHAR
) FROM shop_pg TABLE 'public.customers';
CREATE TABLE tickets (
ticket_id BIGINT PRIMARY KEY,
customer_id BIGINT,
status VARCHAR, -- open, pending, resolved
subject VARCHAR,
opened_at TIMESTAMPTZ
) FROM shop_pg TABLE 'public.tickets';Each view is one context object. The profile joins three inputs and aggregates across all of a customer's history; RisingWave updates the affected row when any input changes. The activity view is a hopping window that advances every minute. The status view keeps only the latest event per order. The embeddings view calls OpenAI once per new article version and stores a vector(1536); since it is append-only it can carry an HNSW index. The memory table is where the agent writes.
-- Entity state: one row per customer, kept current incrementally.
CREATE MATERIALIZED VIEW customer_360 AS
SELECT c.customer_id, c.name, c.tier, c.region,
COUNT(o.order_id) FILTER (WHERE o.status <> 'refunded') AS orders_total,
COALESCE(SUM(o.amount) FILTER (WHERE o.status = 'paid'), 0) AS lifetime_value,
COUNT(o.order_id) FILTER (WHERE o.status = 'refunded') AS refunds_total,
MAX(o.order_ts) AS last_order_at,
COUNT(t.ticket_id) FILTER (WHERE t.status <> 'resolved') AS open_tickets
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
LEFT JOIN tickets t ON t.customer_id = c.customer_id
GROUP BY c.customer_id, c.name, c.tier, c.region;
-- Recent activity: what this customer did in the last 15 minutes.
CREATE MATERIALIZED VIEW session_activity AS
SELECT customer_id,
window_start, window_end,
COUNT(*) FILTER (WHERE event_type = 'page_view') AS page_views,
COUNT(*) FILTER (WHERE event_type = 'add_to_cart') AS cart_adds,
COUNT(*) FILTER (WHERE event_type = 'checkout_error') AS checkout_errors,
ARRAY_AGG(page ORDER BY event_ts DESC) AS recent_pages
FROM HOP(clickstream, event_ts, INTERVAL '1 minute', INTERVAL '15 minutes')
GROUP BY customer_id, window_start, window_end;
-- Live status: the latest event per open order.
CREATE MATERIALIZED VIEW order_status_live AS
SELECT order_id, customer_id, amount, status, order_ts AS updated_at
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY order_ts DESC) AS rn
FROM orders
) t
WHERE rn = 1 AND status NOT IN ('delivered', 'refunded');
-- Retrievable knowledge: embed each article version as it is published.
-- The function is defined once so the model and key live in one place.
CREATE FUNCTION embed(VARCHAR) RETURNS VECTOR(1536) LANGUAGE SQL AS $$
SELECT openai_embedding(
'{"model": "text-embedding-3-small", "api_key": "sk-..."}'::jsonb, $1
)::vector(1536)
$$;
CREATE MATERIALIZED VIEW article_embeddings AS
SELECT article_id, version, title, body, published_at,
embed(title || E'\n' || body) AS embedding
FROM help_articles;
-- Vector indexes are built on append-only inputs; this view is one.
CREATE INDEX article_embeddings_hnsw ON article_embeddings
USING HNSW (embedding)
INCLUDE (article_id, version, title, body)
WITH (distance_type = 'cosine', m = 16, ef_construction = 64);
-- Agent memory: the agent writes here, views read it back.
CREATE TABLE agent_memory (
memory_id BIGINT PRIMARY KEY,
customer_id BIGINT,
kind VARCHAR, -- promise, preference, escalation, summary
content VARCHAR,
created_at TIMESTAMPTZ
);
CREATE MATERIALIZED VIEW customer_memory AS
SELECT customer_id,
ARRAY_AGG(kind || ': ' || content ORDER BY created_at DESC) AS notes
FROM agent_memory
WHERE created_at > NOW() - INTERVAL '90 days'
GROUP BY customer_id;Two queries per turn: one point lookup that returns the whole structured context as JSON, and one vector search for the question at hand. Both are plain SQL, so they work from any driver. The retrieval query uses the same embed function as the index, which keeps the query vector and the stored vectors in the same space.
-- The whole structured context for one customer, from one snapshot.
SELECT to_jsonb(c) AS profile,
to_jsonb(s) AS last_15_min,
(SELECT jsonb_agg(to_jsonb(o))
FROM order_status_live o
WHERE o.customer_id = c.customer_id) AS open_orders,
m.notes AS memory
FROM customer_360 c
LEFT JOIN session_activity s
ON s.customer_id = c.customer_id
AND s.window_end = (SELECT MAX(window_end) FROM session_activity
WHERE customer_id = c.customer_id)
LEFT JOIN customer_memory m ON m.customer_id = c.customer_id
WHERE c.customer_id = $1;
-- The three most relevant help articles for the customer's question,
-- latest version only, embedded with the same function as the index.
SELECT DISTINCT ON (article_id) article_id, title, body
FROM article_embeddings
ORDER BY article_id, version DESC, embedding <=> embed($1)
LIMIT 3;The agent code is short because the hard part moved into the views. It runs the two queries, hands the result to the model as a JSON block, and returns the answer. Any LLM client and any agent framework works; the context is plain data. Writing memory back is one INSERT into agent_memory, which the next turn's context query already reads.
import json, re, psycopg, anthropic
rw = psycopg.connect("postgresql://agent_ro@localhost:4566/dev", autocommit=True)
llm = anthropic.Anthropic() # any LLM client works; the context is plain JSON
CONTEXT_SQL = open("context.sql").read() # the first query above
RETRIEVE_SQL = open("retrieve.sql").read() # the second query above
def bind(sql: str) -> str:
"""The SQL files use Postgres $n placeholders; psycopg wants %(name)s."""
return re.sub(r"\$(\d+)", r"%(p\1)s", sql)
def build_context(customer_id: int, question: str) -> dict:
with rw.cursor() as cur:
cur.execute(bind(CONTEXT_SQL), {"p1": customer_id})
profile, last_15_min, open_orders, memory = cur.fetchone()
cur.execute(bind(RETRIEVE_SQL), {"p1": question})
articles = [{"id": a, "title": t, "body": b} for a, t, b in cur.fetchall()]
return {
"profile": profile,
"last_15_min": last_15_min,
"open_orders": open_orders or [],
"memory": memory or [],
"relevant_articles": articles,
}
def answer(customer_id: int, question: str) -> str:
ctx = build_context(customer_id, question)
resp = llm.messages.create(
model="claude-sonnet-5-5",
max_tokens=600,
system=(
"You are a support agent. Use only the facts in <context>. "
"The context is at most a few seconds old; treat it as current."
),
messages=[{
"role": "user",
"content": f"<context>{json.dumps(ctx, default=str)}</context>\n\n{question}",
}],
)
return resp.content[0].text
print(answer(42, "Where is my order and can I still change the address?"))For agents that run inside Claude Desktop, Cursor or another MCP host, skip the Python and point the RisingWave MCP server at the same read-only user. Its query tools let the model run the two queries above itself; its schema tools let it discover which context views exist. The server supports stdio, streamable HTTP and SSE transports.
{
"mcpServers": {
"risingwave": {
"command": "python",
"args": ["/path/to/risingwave-mcp/src/main.py"],
"env": {
"MCP_TRANSPORT": "stdio",
"RISINGWAVE_CONNECTION_STR": "postgresql://agent_ro@localhost:4566/dev"
}
}
}
}Everything above is standard RisingWave SQL as documented in the vector indexes, AI functions and PostgreSQL CDC pages; the MCP server is at github.com/risingwavelabs/risingwave-mcp.
Alternatives
Every approach below works for some teams. The question is how many systems you want between an event and the agent, and whether the agent can get structured state and semantic retrieval from the same place.
| Approach | How it works | Freshness | Trade-off |
|---|---|---|---|
| Agent assembles context with tool calls | The agent calls the orders API, the CRM, the ticket system and a vector store on every turn and reasons over the raw results. | Live, per call | Slow and token-hungry; each call sees a different moment; every new context field is a new tool. |
| Flink or Kafka Streams plus a key-value cache | A stream job computes features and writes them to Redis or DynamoDB; the agent reads by key. | Seconds | Fast reads, but no SQL at read time, no joins across cached objects, and a Java job plus a cache to operate. |
| Feature store (Tecton, Feast) | Features are defined in Python, computed in batch or streaming, and served from an online store. | Seconds to minutes | Built for fixed-schema models; text retrieval, joins at read time and ad hoc context need other systems. |
| PostgreSQL plus pgvector plus cron | Nightly or hourly jobs aggregate operational tables and re-embed changed documents into pgvector. | Minutes to hours | Simple and familiar, but context is stale between jobs and heavy aggregations compete with the production database. |
| Vector database plus batch embedding pipeline | Documents are embedded in a scheduled pipeline and upserted into Pinecone, Weaviate, Qdrant or Milvus. | Per pipeline run | Strong at large static corpora; no structured state, so the agent still needs a second source for live facts. |
| Confluent and Redpanda agent features | Confluent exposes Flink-based streaming agents and Tableflow; Redpanda ships an MCP server and an agentic data plane over topics. | Seconds | Tied to the streaming platform's cloud; context is still assembled by jobs the team writes and operates. |
| RisingWave (this page) | Context objects, windows, embeddings and memory are materialized views over Kafka and CDC; agents read them via SQL or MCP. | About one second | One system to run; vector indexes require append-only inputs; very large static corpora are better served by a dedicated vector store. |
Design Decisions
Put anything that is expensive, shared across turns, or needs a consistent snapshot into a view: aggregates, joins, windows, embeddings. Leave per-turn filtering (which three articles, which open order the question is about) to the read query. A good test: if two agent turns in the same minute would compute the same thing, it belongs in a view.
Shape the view for the prompt, not for a dashboard. Return counts and the latest few items (ARRAY_AGG with ORDER BY and a slice) rather than full histories, cast to JSON in SQL so the agent code does no reshaping, and keep retrieval at LIMIT 3 to 5. In the reference implementation the whole context is typically under 1,500 tokens.
Embed in the stream when documents change often or when a stale answer is costly (pricing, policy, outage notices). Batch is fine for a static corpus. In RisingWave the cost is one embedding API call per new document version, made once, with retries and Prometheus metrics built into openai_embedding.
Use the default (emit on every change) for context the agent reads live, such as the last-15-minutes activity view, so the current window is visible. Use EMIT ON WINDOW CLOSE for windows that feed downstream systems or memory, where you want one final value per window instead of a stream of updates.
Treat memory as another event stream. The agent INSERTs into agent_memory; a view summarises it per customer; the context query joins it in. Because memory and live data live in one system, you can also check memory against reality, for example a view that flags promises whose related order did not ship.
Give the agent a read-only database user with SELECT on the context views only, never on raw sources. Keep write access limited to the agent_memory table. If the agent uses the MCP server, run it with that same read-only connection string so the 100+ management tools cannot change the pipeline.
When Not to Use This
Run the reference implementation on RisingWave Cloud or a single Docker container.
Start Free