showcase/shell-docs/src/content/docs/frontends/angular/guides/human-in-the-loop.mdx
Human-in-the-loop flows pause agent work until a person supplies a decision. Angular has two paths with different owners.
| Pattern | Who chooses the pause? | Angular API |
|---|---|---|
| Human-in-the-loop tool | The agent calls a registered browser tool | registerHumanInTheLoop |
| Interrupt | The backend agent emits an AG-UI interrupt | AgentStore.interruptController, injectInterrupt |
Use a tool when the model should decide whether to ask. Use an interrupt when the backend workflow must stop at a fixed checkpoint.
The renderer receives a toolCall signal. Call respond(result) once the user
has made a choice.
import { Component, input } from "@angular/core";
import {
type HumanInTheLoopToolCall,
type HumanInTheLoopToolRenderer,
} from "@copilotkit/angular";
type ApprovalArgs = {
action: string;
reason: string;
};
@Component({
selector: "app-approval-card",
template: `
@let call = toolCall();
<article>
<h3>Approve {{ call.args.action ?? "this action" }}?</h3>
<p>{{ call.args.reason }}</p>
@if (call.status !== "complete") {
<button type="button" (click)="call.respond({ approved: true })">
Approve
</button>
<button type="button" (click)="call.respond({ approved: false })">
Reject
</button>
}
</article>
`,
})
export class ApprovalCardComponent
implements HumanInTheLoopToolRenderer<ApprovalArgs>
{
readonly toolCall =
input.required<HumanInTheLoopToolCall<ApprovalArgs>>();
}
Register the tool from the component or service that owns the decision UI:
import { Injectable } from "@angular/core";
import { registerHumanInTheLoop } from "@copilotkit/angular";
import { z } from "zod";
import { ApprovalCardComponent } from "./approval-card.component";
@Injectable()
export class ApprovalToolsService {
constructor() {
registerHumanInTheLoop({
name: "requestApproval",
description: "Ask the user before a consequential action",
parameters: z.object({
action: z.string(),
reason: z.string(),
}),
component: ApprovalCardComponent,
});
}
}
There is no handler. CopilotKit supplies one that waits for respond, returns
the decision to the agent, and continues the run. The registration is removed
when the owning injector is destroyed.
An interrupt is a state of one conversation: this agent, this thread, this run is waiting for a decision. The store that already exposes that conversation's messages and state exposes its pending interrupt too, so a component that holds a store needs nothing else:
import { Component } from "@angular/core";
import { injectAgentStore } from "@copilotkit/angular";
@Component({
selector: "app-ticket-approval",
template: `
@let interrupts = store().interruptController;
@if (interrupts.hasInterrupt()) {
<section>
<p>{{ interrupts.interrupt()?.message }}</p>
<button type="button" (click)="interrupts.resolve({ approved: true })">
Approve
</button>
<button type="button" (click)="interrupts.cancel()">Reject</button>
</section>
}
`,
})
export class TicketApprovalComponent {
protected readonly store = injectAgentStore("ticketing");
}
The controller is created and connected with the store, then destroyed when the
store is torn down or replaced. Standard AG-UI interrupts already retained by
the agent are visible immediately, and legacy on_interrupt events are observed
for the store's full lifetime.
injectInterrupt subscribes to one agent and exposes the pending decision as
signals. It supports standard AG-UI interrupts and the legacy
on_interrupt custom event. Use it when the store default is not enough — a
typed payload, an enabled filter, or a handler that prepares data for the
view.
import { Component } from "@angular/core";
import { injectInterrupt } from "@copilotkit/angular";
type ReviewRequest = {
title?: string;
choices?: Array<{ id: string; label: string }>;
};
@Component({
selector: "app-interrupt-panel",
template: `
@if (controller.event(); as event) {
@let request = asReviewRequest(event.value);
<section aria-labelledby="review-title">
<h2 id="review-title">{{ request.title ?? "Review required" }}</h2>
@for (choice of request.choices ?? []; track choice.id) {
<button type="button" (click)="resolve(choice.id)">
{{ choice.label }}
</button>
}
<button type="button" (click)="cancel()">Cancel</button>
</section>
}
@if (controller.error()) {
<p role="alert">The decision could not be submitted.</p>
}
`,
})
export class InterruptPanelComponent {
protected readonly controller =
injectInterrupt<ReviewRequest>("default");
protected asReviewRequest(value: unknown): ReviewRequest {
return typeof value === "object" && value !== null
? (value as ReviewRequest)
: {};
}
protected resolve(choiceId: string): void {
this.controller.resolve({ choiceId }).catch(() => undefined);
}
protected cancel(): void {
this.controller.cancel().catch(() => undefined);
}
}
The controller clears stale decisions when the thread changes. Calls to
resolve or cancel share one in-flight resume promise, so a double click
does not start two resume runs.
The runnable Showcase uses the same controller API in its route-aware feature:
<AngularSnippet region="interrupt-controller" />Use the controller's enabled option when several components listen to the
same agent and each should accept only certain interrupt payloads. Use
handler when the view needs async data prepared before it appears.
The tool renderer appears in the tool-call flow inside chat. An interrupt controller is headless: bind its signals anywhere in the application, including a route-level dialog, side panel, or task view.