diff --git a/apps/website/content/docs/chat/a2ui/overview.mdx b/apps/website/content/docs/chat/a2ui/overview.mdx
index ca7850448..47a2b171b 100644
--- a/apps/website/content/docs/chat/a2ui/overview.mdx
+++ b/apps/website/content/docs/chat/a2ui/overview.mdx
@@ -160,6 +160,33 @@ export class MyComponent {
}
```
+## Custom Function Call Handlers
+
+When an A2UI button has a `functionCall` action, the `call` name is looked up in the `[handlers]` map on `ChatComponent`. This lets you define client-side behavior triggered by agent-built UI:
+
+```typescript
+@Component({
+ template: ``,
+})
+export class MyComponent {
+ agentRef = agent({ apiUrl: '/api', assistantId: 'my-agent' });
+ catalog = a2uiBasicCatalog();
+
+ handlers = {
+ addToCart: async (args: Record) => {
+ const cart = inject(CartService);
+ return cart.add(args['sku'] as string);
+ },
+ };
+}
+```
+
+The agent sends a button with `{"action": {"functionCall": {"call": "addToCart", "args": {"sku": "ABC"}}}}`. When clicked, the `addToCart` handler runs in Angular's injection context — `inject()` works for accessing services.
+
+If no consumer handler matches the `call` name, built-in handlers are used as fallbacks (e.g., `openUrl` opens a URL in a new tab).
+
+Handler return values are emitted on the `RenderHandlerEvent` — observe them via the `renderEvent` output on `ChatComponent`.
+
## What's Next
diff --git a/apps/website/content/docs/chat/components/chat.mdx b/apps/website/content/docs/chat/components/chat.mdx
index 78c3c1de4..fc4a9ca53 100644
--- a/apps/website/content/docs/chat/components/chat.mdx
+++ b/apps/website/content/docs/chat/components/chat.mdx
@@ -64,6 +64,7 @@ export class ChatPageComponent {
| `ref` | `AgentRef` | **Required** | The agent ref providing streaming state. Created by `agent()` from `@cacheplane/angular`. |
| `views` | `ViewRegistry \| undefined` | `undefined` | View registry for generative UI. Maps spec type names to Angular components. Created with `views()` from `@cacheplane/chat`. |
| `store` | `StateStore \| undefined` | `undefined` | Optional state store for interactive generative UI specs. |
+| `handlers` | `Record) => unknown \| Promise>` | `{}` | Event handlers for generative UI specs and A2UI `functionCall` actions. Handlers run in Angular injection context — `inject()` is available inside handler functions. |
| `threads` | `Thread[]` | `[]` | List of threads to display in the sidebar. Each thread must have an `id` property. |
| `activeThreadId` | `string` | `''` | The ID of the currently active thread, used for highlighting in the sidebar. |
diff --git a/apps/website/content/docs/render/guides/events.mdx b/apps/website/content/docs/render/guides/events.mdx
index 0ff844be9..608c79693 100644
--- a/apps/website/content/docs/render/guides/events.mdx
+++ b/apps/website/content/docs/render/guides/events.mdx
@@ -152,6 +152,23 @@ Each handler receives a `params` object and can return a value or a `Promise`:
type Handler = (params: Record) => unknown | Promise;
```
+### Injection Context
+
+Handlers execute inside Angular's `runInInjectionContext`. This means you can call `inject()` to access services:
+
+```typescript
+const handlers = {
+ saveForm: async (params: Record) => {
+ const http = inject(HttpClient);
+ const snapshot = store.getSnapshot();
+ await firstValueFrom(http.post('/api/forms', snapshot));
+ store.set('/saved', true);
+ },
+};
+```
+
+This works for handlers passed via `[handlers]` on ``, `provideRender()`, or `ChatComponent`.
+
### Resolution Priority
Handlers resolve with the same priority as other inputs:
diff --git a/libs/chat/src/lib/a2ui/surface.component.spec.ts b/libs/chat/src/lib/a2ui/surface.component.spec.ts
index 8bb33a540..c46c88b47 100644
--- a/libs/chat/src/lib/a2ui/surface.component.spec.ts
+++ b/libs/chat/src/lib/a2ui/surface.component.spec.ts
@@ -127,3 +127,29 @@ describe('surfaceToSpec — state initialization', () => {
expect(spec.state).toEqual({ count: 0, name: 'test' });
});
});
+
+describe('A2uiSurfaceComponent — consumer handlers', () => {
+ function makeSurface(components: A2uiComponent[], dataModel: Record = {}): A2uiSurface {
+ const map = new Map();
+ for (const c of components) map.set(c.id, c);
+ return { surfaceId: 's1', catalogId: 'basic', components: map, dataModel };
+ }
+
+ it('maps functionCall action call name to a2ui:localAction params', () => {
+ const surface = makeSurface([
+ { id: 'root', component: 'Column', children: ['btn'] },
+ {
+ id: 'btn',
+ component: 'Button',
+ label: 'Add',
+ action: { functionCall: { call: 'addToCart', args: { sku: 'ABC' } } },
+ },
+ ]);
+ const spec = surfaceToSpec(surface)!;
+ const btnElement = spec.elements['btn'];
+ expect(btnElement.on!['click']).toEqual({
+ action: 'a2ui:localAction',
+ params: { call: 'addToCart', args: { sku: 'ABC' } },
+ });
+ });
+});
diff --git a/libs/chat/src/lib/a2ui/surface.component.ts b/libs/chat/src/lib/a2ui/surface.component.ts
index 24f908260..1d2bf7529 100644
--- a/libs/chat/src/lib/a2ui/surface.component.ts
+++ b/libs/chat/src/lib/a2ui/surface.component.ts
@@ -112,7 +112,7 @@ export function surfaceToSpec(surface: A2uiSurface): Spec | null {
}
@@ -121,6 +121,7 @@ export function surfaceToSpec(surface: A2uiSurface): Spec | null {
export class A2uiSurfaceComponent {
readonly surface = input.required();
readonly catalog = input.required();
+ readonly handlers = input) => unknown | Promise>>({});
readonly events = output();
/** Convert the A2UI surface to a json-render Spec for rendering. */
@@ -129,19 +130,30 @@ export class A2uiSurfaceComponent {
/** Convert ViewRegistry to AngularRegistry for RenderSpecComponent. */
readonly registry = computed(() => toRenderRegistry(this.catalog()));
- readonly handlers: Record) => unknown> = {
- 'a2ui:event': (params) => {
- return params;
- },
- 'a2ui:localAction': (params) => {
- const call = params['call'] as string;
- const args = (params['args'] as Record) ?? {};
- if (call === 'openUrl' && typeof globalThis.window !== 'undefined') {
- globalThis.window.open(String(args['url'] ?? ''), '_blank');
- }
- return undefined;
- },
- };
+ /** Merge built-in A2UI handlers with consumer-provided handlers. */
+ readonly internalHandlers = computed(() => {
+ const consumerHandlers = this.handlers();
+ return {
+ 'a2ui:event': (params: Record) => {
+ return params;
+ },
+ 'a2ui:localAction': (params: Record) => {
+ const call = params['call'] as string;
+ const args = (params['args'] as Record) ?? {};
+
+ // Consumer handler takes priority
+ if (consumerHandlers[call]) {
+ return consumerHandlers[call](args);
+ }
+
+ // Built-in fallback
+ if (call === 'openUrl' && typeof globalThis.window !== 'undefined') {
+ globalThis.window.open(String(args['url'] ?? ''), '_blank');
+ }
+ return undefined;
+ },
+ };
+ });
onRenderEvent(event: RenderEvent): void {
this.events.emit(event);
diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts
index fcaf64bd7..c5ecd584a 100644
--- a/libs/chat/src/lib/compositions/chat/chat.component.ts
+++ b/libs/chat/src/lib/compositions/chat/chat.component.ts
@@ -141,6 +141,7 @@ import { KeyValuePipe } from '@angular/common';
[spec]="spec"
[registry]="renderRegistry()"
[store]="store()"
+ [handlers]="handlers()"
[loading]="ref().isLoading()"
(events)="onSpecEvent($event, index)"
/>
@@ -152,6 +153,7 @@ import { KeyValuePipe } from '@angular/common';
}
@@ -217,6 +219,7 @@ export class ChatComponent {
readonly ref = input.required>();
readonly views = input(undefined);
readonly store = input(undefined);
+ readonly handlers = input) => unknown | Promise>>({});
readonly threads = input([]);
readonly activeThreadId = input('');
readonly threadSelected = output();
diff --git a/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts b/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts
index ff6df2ea2..6a7bdab39 100644
--- a/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts
+++ b/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts
@@ -20,6 +20,7 @@ import { RenderSpecComponent } from '@cacheplane/render';
[spec]="spec()"
[registry]="registry()"
[store]="store()"
+ [handlers]="handlers()"
[loading]="loading()"
(events)="events.emit($event)"
/>
@@ -30,6 +31,7 @@ export class ChatGenerativeUiComponent {
readonly spec = input(null);
readonly registry = input(undefined);
readonly store = input(undefined);
+ readonly handlers = input) => unknown | Promise> | undefined>(undefined);
readonly loading = input(false);
readonly events = output();
}
diff --git a/libs/render/src/lib/render-element.component.spec.ts b/libs/render/src/lib/render-element.component.spec.ts
index 367fa459c..3b10de01b 100644
--- a/libs/render/src/lib/render-element.component.spec.ts
+++ b/libs/render/src/lib/render-element.component.spec.ts
@@ -327,6 +327,30 @@ describe('RenderElementComponent — children rendering', () => {
});
});
+describe('RenderElementComponent — handler injection context', () => {
+ it('should allow handlers to call inject() inside runInInjectionContext', () => {
+ TestBed.configureTestingModule({});
+ TestBed.runInInjectionContext(() => {
+ const { Injector, runInInjectionContext, inject, DestroyRef } = require('@angular/core');
+ const injector = Injector.create({ providers: [] });
+
+ let destroyRefAccessed = false;
+ const handler = (params: Record) => {
+ // This would throw outside injection context
+ runInInjectionContext(injector, () => {
+ const dr = inject(DestroyRef);
+ destroyRefAccessed = dr !== undefined;
+ });
+ return params;
+ };
+
+ const result = handler({ test: true });
+ expect(destroyRefAccessed).toBe(true);
+ expect(result).toEqual({ test: true });
+ });
+ });
+});
+
describe('RenderElementComponent — element-level memoization', () => {
it('element lookup returns same reference when spec changes but element is unchanged', () => {
TestBed.configureTestingModule({});
diff --git a/libs/render/src/lib/render-element.component.ts b/libs/render/src/lib/render-element.component.ts
index 43131da85..fb2f647a2 100644
--- a/libs/render/src/lib/render-element.component.ts
+++ b/libs/render/src/lib/render-element.component.ts
@@ -8,6 +8,7 @@ import {
Injector,
input,
OnInit,
+ runInInjectionContext,
type Signal,
} from '@angular/core';
import { NgComponentOutlet } from '@angular/common';
@@ -135,7 +136,9 @@ export class RenderElementComponent implements OnInit {
for (const b of bindings) {
const handler = this.ctx.handlers?.[b.action];
if (handler) {
- handler(b.params as Record ?? {});
+ runInInjectionContext(this.parentInjector, () =>
+ handler(b.params as Record ?? {}),
+ );
}
}
};