showcase/shell-docs/src/content/docs/cookbook/angular-adk-agentic-app.mdx
This recipe assembles a production agentic app from three pieces: an Angular frontend built with @copilotkit/angular, a Google ADK agent (running Gemini by default, or any model ADK supports) served over the open AG-UI protocol, and an optional CopilotKit Intelligence layer for persistent threads and cross-session memory.
Both halves have their own quickstarts already, and this recipe does not repeat them:
What this recipe covers is the part that bites once the two are wired together: the handful of correctness issues that only show up in a real, multi-user, governed app. Each one is framed as symptom, cause, and fix.
Three processes, with one load-bearing seam between them:
injectAgentStore("default") returns a shared store keyed by agent id. Every component that injects "default" sees the same messages and run state.
import { Component } from "@angular/core";
import { CopilotChat, injectAgentStore } from "@copilotkit/angular";
@Component({
selector: "app-chat",
imports: [CopilotChat],
template: `<copilot-chat agentId="default" />`,
// Give the surface a bounded height, or the composer slides off-screen on long threads.
styles: [`:host { display: block; height: 100%; min-height: 0; }`],
})
export class ChatComponent {
readonly agentStore = injectAgentStore("default");
}
This is the single most common correctness bug on this stack.
<Callout type="error" title="An HTTP header lags by one user switch"> **Symptom:** right after switching users in the same session, the agent recalls the *previous* user's data. Fresh page loads are always fine. **Cause:** if you identify the user with an HTTP header, the transport reuses connections, so the outbound run still carries the prior user's header for one run after an in-session switch. **Fix:** put the user id in the **run body**, which is serialized fresh on every run and never lags. </Callout>Carry the user id as agent context, not as properties. This matters on ADK: the adapter copies the run's state and context into the ADK session, but it does not mirror forwardedProps (CopilotKit properties) into session state. A user id sent through properties never reaches tool_context.state, so the scoping silently fails. Use connectAgentContext, which rides the run body and is serialized fresh every run:
import { Component, signal } from "@angular/core";
import { connectAgentContext } from "@copilotkit/angular";
@Component({ /* ... */ })
export class ChatComponent {
readonly userId = signal(currentUserId);
constructor() {
// Re-registers reactively when the signal changes (e.g. an in-session user switch).
connectAgentContext(() => ({ description: "userId", value: this.userId() }));
}
}
On the agent side, the AG-UI adapter stores context under the _ag_ui_context key, so a tool reads the user id from there:
def _user_id(tool_context) -> str | None:
for entry in tool_context.state.get("_ag_ui_context", []):
if entry.get("description") == "userId":
return entry.get("value")
return None
def recall_for_user(tool_context) -> dict:
user_id = _user_id(tool_context)
# ...scope every read and write to user_id
return {"ok": True}
Shared agent state works too, and lands directly as tool_context.state["userId"] (no _ag_ui_context scan). Pick one. If your BFF also accepts a user header, prefer the run-body value over the header when scoping, so a stale header can never win.
ADK runs Google's Gemini by default, but it is model-flexible: you can point it at any model ADK supports (see the ADK quickstart). Whatever you pick, the agentic-flow guidance is the same.
Two more agent-side habits:
pieChart versus PieChart) on both the inbound strip and the outbound filter, or governance leaks through a casing mismatch.CopilotKit Intelligence adds persistent threads and durable cross-session memory. Build so a missing or unreachable platform degrades gracefully instead of erroring on every call:
Memory tools call the platform's streaming JSON-RPC /mcp endpoint, scoped per user:
POST {INTELLIGENCE_API_URL}/mcp
Authorization: Bearer <key>
X-Cpki-User-Id: <user_id>
Accept: application/json, text/event-stream
{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"recall_memory","arguments":{}}}
The response is a server-sent event stream. Read the JSON-RPC body from the first data: line and join the text content. Writes hit an embedding step and can take several seconds, so give the call generous timeout headroom and keep it async.
tsx/node BFF and a Python agent do not, so restart them after edits. Give each service a stable, non-colliding port and write them down.messageViewComponent rather than a template, and iterate the full message list yourself to interleave generative UI. See the Angular guide.Paste this into your coding agent (Cursor, Claude Code, etc.) once you have the Angular and ADK quickstarts running:
In my Angular + Google ADK + CopilotKit app, harden it for multi-user production:
1. Standardize on one chat surface: a single `injectAgentStore("default")`, and route every
composer (including any welcome composer) through that one shared store.
2. Scope the user via the run body, not a header. On ADK, `forwardedProps` (CopilotKit `properties`)
is NOT mirrored into session state, so carry the user id as agent context with
`connectAgentContext({ description: "userId", value: userId })`, and read it in ADK tools by
scanning `tool_context.state["_ag_ui_context"]` for the `userId` entry (or use shared agent state,
which lands directly as `tool_context.state["userId"]`). If the BFF also accepts a user header,
prefer the run-body value over the header.
3. Never mutate runtime config (runtimeUrl/headers) during a submit. Do config changes before the
chat mounts. Carry per-request values in the run body instead.
4. Enforce governance on the server: strip disallowed tools from the inbound run body and filter the
outbound generative-UI stream. Make the allow-list ride the run body, and normalize tool-name casing.
5. Give the chat surface a bounded height, and only fetch thread history for an existing thread id.
Keep my existing Angular and ADK setup otherwise unchanged.