Page actions

Angular Signals

injectAgent() exposes the agent's state through read-only Angular Signals. Read messages() to render the conversation, derive a view model with computed(), and let Angular update the consumers when that state changes. Commands such as submit(), stop(), and retry() remain methods; they start or control work rather than read state.

Note: For Python developers

Think of a Signal as a value with dependency tracking. Calling it reads the current value. Angular tracks those reads in templates, computed values, and effects so it knows which consumers need an update.

What Are Angular Signals?

A Signal is a reactive value container introduced in Angular 16+. You create one, read it by calling it like a function, and Angular tracks which templates and computations depend on it.

import { signal, computed } from '@angular/core';
 
// Create a writable signal
const count = signal(0);
 
// Read the current value — call it like a function
console.log(count()); // 0
 
// Update the value
count.set(1);
count.update(prev => prev + 1);
 
// Derive new values with computed()
const doubled = computed(() => count() * 2);
console.log(doubled()); // 4

Angular tracks which Signals a template reads. When a dependency changes, Angular can schedule the consuming view for an update. Reading a Signal does not start another request or subscribe to a second agent run.

How injectAgent Uses Signals Internally

injectAgent() retrieves the agent registered by provideAgent(). The LangGraph adapter accumulates transport events into messages and run state, then exposes that state through Signals. A text delta updates an existing assistant message; it does not create a separate conversation message for every token.

The adapter uses RxJS internally, but application code reads the public Signals. The example below shows fields and reads inside an Angular injection context. Configure the same typed reference with provideAgent(); see State Management.

import { injectAgent } from '@threadplane/langgraph';
import { createAgentRef } from '@threadplane/chat';
import type { BaseMessage } from '@langchain/core/messages';
 
// The type parameter mirrors your graph's state schema — see State Management.
interface ChatState {
  messages: BaseMessage[];
}
 
export const CHAT_AGENT = createAgentRef<ChatState>('chat');
 
// You never touch BehaviorSubjects or toSignal() yourself.
// injectAgent(ref) hands you clean Signals (config comes from provideAgent):
protected readonly chat = injectAgent(CHAT_AGENT);
 
chat.messages();   // Current message array
chat.status();     // 'idle' | 'running' | 'error'
chat.error();      // AgentError | undefined
chat.isLoading();  // boolean
 
// LangGraph-specific (see note below):
chat.value();      // ChatState
Note: `value()` is LangGraph-specific

messages(), status(), error(), and isLoading() are part of the runtime-neutral Agent contract that every adapter implements. value() is not — it lives on LangGraphAgent. You can call chat.value() here only because injectAgent() returns a LangGraphAgent. Code written against the neutral Agent contract should read agent state through state() rather than value().

Tip: Why this matters

Signals describe the latest state. They do not guarantee reconnection, backpressure, or durable storage. Those behaviors depend on the adapter, transport, and server. See Streaming and Persistence for those boundaries.

The Streaming Lifecycle as Signals

The agent moves from idle to running while a run is in flight, then returns to idle when it settles or error when it fails. isLoading() is a convenience signal for loading UI. Check interrupt() separately for an approval pause: an idle transport does not by itself mean the user's turn is complete.

1
idle — Waiting for input

The resource has been created but no request has been submitted yet. All Signals hold their initial values.

protected readonly chat = injectAgent();
 
console.log(chat.status());     // 'idle'
console.log(chat.messages());   // []
console.log(chat.isLoading());  // false
2
running — Request in flight

Calling submit() starts a run and optimistically adds the user's message. running means work is in flight; it does not prove that the server has received the request or sent its first event.

chat.submit({ message: 'Plan a weekend in Portland.' });
 
console.log(chat.status());     // 'running'
console.log(chat.isLoading());  // true
console.log(chat.messages().map(message => message.role)); // ['user']
3
running — Tokens streaming

As the agent generates tokens, the messages() Signal updates with each chunk. The status remains 'running' throughout.

// After first few tokens arrive:
console.log(chat.status());     // 'running' (still streaming)
console.log(chat.messages().map(message => message.role)); // ['user', 'assistant']
 
// After more tokens:
console.log(chat.messages().length); // 2
// The assistant's content grows; the existing user message stays in the list.
4
idle — Stream complete

The agent has finished. All tokens have arrived. The status transitions back to 'idle'.

console.log(chat.status());     // 'idle'
console.log(chat.isLoading());  // false
console.log(chat.messages().map(message => message.role)); // ['user', 'assistant']
5
error — Something went wrong

If the agent fails or the connection drops, the status transitions to 'error' and the error() Signal contains the failure details.

console.log(chat.status());     // 'error'
console.log(chat.error());      // AgentError { kind: 'server', retryable: true, status: 500, ... }
console.log(chat.isLoading());  // false

Composing Derived State with computed()

computed() lets you derive a value from agent Signals. When a dependency changes, Angular invalidates the cached result and recomputes it on the next read.

import { computed } from '@angular/core';
import { injectAgent } from '@threadplane/langgraph';
 
protected readonly chat = injectAgent();
 
// Count all messages in the conversation
const messageCount = computed(() => chat.messages().length);
 
// Get the last message (useful for showing the latest response)
const lastMessage = computed(() => chat.messages().at(-1));
 
// Extract just the assistant's messages
const assistantMessages = computed(() =>
  chat.messages().filter(m => m.role === 'assistant')
);
 
// Read tool calls, including calls that have already completed
const toolCalls = computed(() => chat.toolCalls());
 
// Build a user-facing error message
const errorDisplay = computed(() => {
  const err = chat.error();
  if (!err) return null;
  if (err.status === 429) return 'Rate limited. Please wait a moment.';
  if (err.kind === 'auth') return 'Your credentials were rejected.';
  return err.message;
});
 
// Combine multiple signals into a single view model
const viewModel = computed(() => ({
  messages: chat.messages(),
  isStreaming: chat.isLoading(),
  canSend: !chat.isLoading(),
  messageCount: messageCount(),
  error: errorDisplay(),
}));
Tip: computed() is lazy and cached

A computed() caches its result until a dependency changes, then recalculates when read. Keep the calculation free of side effects. A stream may contain many events between rendered frames, so use the latest state rather than treating a computed value as an event log.

Side Effects with effect()

Use effect() when a Signal change should trigger work that lives outside the template — logging, analytics, scrolling, persisting state. Effects run in the injection context and are automatically cleaned up when the component is destroyed.

import { effect } from '@angular/core';
 
// Log errors for observability
effect(() => {
  const err = chat.error();
  if (err) {
    console.error('[Agent] Agent error:', err);
    this.analytics.track('agent_error', { error: err });
  }
});
 
// Auto-scroll to bottom when new messages arrive
effect(() => {
  const msgs = chat.messages();
  if (msgs.length > 0) {
    // Schedule after Angular renders the new message
    setTimeout(() => {
      this.chatContainer.nativeElement.scrollTo({
        top: this.chatContainer.nativeElement.scrollHeight,
        behavior: 'smooth',
      });
    });
  }
});
 
// Track streaming duration for performance monitoring
effect(() => {
  const status = chat.status();
  if (status === 'running') {
    this.streamStart = performance.now();
  }
  if (status === 'idle' && this.streamStart) {
    const duration = performance.now() - this.streamStart;
    this.analytics.track('stream_duration_ms', { duration });
    this.streamStart = null;
  }
});
Warning: Avoid writing to Signals inside effect()

Writing to a Signal inside an effect() can create infinite loops. If you need to transform one Signal into another, use computed() instead. Reserve effect() for side effects that leave the reactive graph — DOM manipulation, logging, analytics, network calls.

Template Patterns

Angular's control flow syntax (@if, @for, @switch) works naturally with Signals. This text chat example reads run state, renders each message and tool call once, and offers recovery controls only when the adapter supports them. Configure provideAgent() as in the Quick Start. Add an interrupt UI when your graph asks for approval.

import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, signal, ViewChild } from '@angular/core';
import { messageContent } from '@threadplane/chat';
import { injectAgent } from '@threadplane/langgraph';
 
@Component({
  selector: 'app-chat',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <!-- Status bar -->
    @switch (chat.status()) {
      @case ('running') {
        <div class="status-bar streaming" role="status">
          Agent is responding...
        </div>
      }
      @case ('error') {
        <div class="status-bar error" role="alert">
          {{ errorDisplay() }}
          @if (statusCheckError(); as message) {
            <p>{{ message }}</p>
          }
          @if (canCheck()) {
            <button type="button" (click)="checkStatus()" [disabled]="checkingStatus()">
              {{ checkingStatus() ? 'Checking status...' : 'Check status' }}
            </button>
          } @else if (canRetry()) {
            <button type="button" (click)="retry()">Retry</button>
          }
        </div>
      }
    }
 
    <!-- Message list -->
    <div class="messages" #chatContainer>
      @for (message of chat.messages(); track message.id) {
        <div [class]="'message ' + message.role">
          {{ messageContent(message) }}
        </div>
      } @empty {
        <div class="empty-state">
          Send a message to start the conversation.
        </div>
      }
    </div>
 
    @if (chat.toolCalls().length) {
      <section aria-label="Tool activity">
        @for (tool of chat.toolCalls(); track tool.id) {
          <p>Called: {{ tool.name }}</p>
        }
      </section>
    }
 
    <!-- Input area -->
    <form (submit)="send($event, input)">
      <label for="chat-message">Message</label>
      <input
        #input
        id="chat-message"
        name="message"
        type="text"
        placeholder="Type a message..."
        [disabled]="chat.isLoading() || checkingStatus()"
      />
      <button type="submit" [disabled]="chat.isLoading() || checkingStatus()">
        @if (chat.isLoading()) { Streaming... } @else { Send }
      </button>
    </form>
  `,
})
export class ChatComponent {
  @ViewChild('chatContainer') chatContainer!: ElementRef<HTMLElement>;
 
  protected readonly chat = injectAgent();
  protected readonly messageContent = messageContent;
  protected readonly checkingStatus = signal(false);
  protected readonly statusCheckError = signal<string | undefined>(undefined);
  protected readonly canRetry = computed(
    () => !this.chat.isLoading() && this.chat.error()?.retryable === true,
  );
  protected readonly canCheck = computed(
    () => !this.chat.isLoading() && this.chat.error()?.recovery === 'check'
      && this.chat.checkStatus !== undefined,
  );
 
  errorDisplay = computed(() => {
    const err = this.chat.error();
    if (!err) return '';
    return err.status
      ? `Error ${err.status}: ${err.message}`
      : err.message;
  });
 
  scrollEffect = effect(() => {
    const msgs = this.chat.messages();
    if (msgs.length) {
      setTimeout(() =>
        this.chatContainer?.nativeElement.scrollTo({
          top: this.chatContainer.nativeElement.scrollHeight,
          behavior: 'smooth',
        })
      );
    }
  });
 
  send(event: Event, input: HTMLInputElement) {
    event.preventDefault();
    const content = input.value.trim();
    if (!content || this.chat.isLoading() || this.checkingStatus()) return;
 
    this.statusCheckError.set(undefined);
    this.chat.submit({ message: content });
    input.value = '';
  }
 
  retry() {
    if (!this.canRetry()) return;
    this.statusCheckError.set(undefined);
    void this.chat.retry();
  }
 
  async checkStatus() {
    if (!this.canCheck() || this.checkingStatus()) return;
    this.statusCheckError.set(undefined);
    this.checkingStatus.set(true);
    try {
      await this.chat.checkStatus?.();
    } catch {
      this.statusCheckError.set(
        'Status could not be checked. The previous outcome is still unconfirmed.',
      );
    } finally {
      this.checkingStatus.set(false);
    }
  }
}

messageContent() reads text from either string or block content. This example renders that text with escaped interpolation; use the chat components when you need Markdown, attachments, and richer tool views. retry() reuses the captured request. Submitting "Please try again" would create another user turn. For an uncertain outcome, use the adapter's status check when available instead of starting the work again.

OnPush Change Detection

Use ChangeDetectionStrategy.OnPush for components that read agent Signals. Angular tracks those reads and marks the consuming view when a value changes.

OnPush lets Angular skip eligible subtrees that do not need an update. The amount of work still depends on your component tree, event handlers, and change detection configuration. A token count is not a count of rendered frames or component checks.

Common reasons an OnPush component needs checking include:

  1. An @Input() reference changes
  2. An event fires inside the component's template
  3. A Signal that the template reads changes
  4. Application code or an integration explicitly marks the view for checking

Since injectAgent() exposes Signals, template reads participate in this mechanism. You do not need a separate manual subscription just to display the latest messages.

@Component({
  // Signal reads notify this OnPush view.
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p>{{ chat.messages().length }} messages</p>
    @if (chat.isLoading()) {
      <app-typing-indicator />
    }
  `,
})
export class ChatComponent {
  protected readonly chat = injectAgent();
}
Note: No markForCheck() needed

For values read as Signals in the template, Angular handles the notification. The async pipe also marks its view when an Observable emits; a manual subscription needs its own change notification and teardown strategy.

Python Agent to Angular Signals

Reuse the Angular component above with this small tool-call slice of the trip-planning example. The Python graph owns the model and tool execution; Angular renders the streamed state. The weather response is fixed demo data. Replace that function with your application's weather service when adapting the example.

from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
 
llm = ChatOpenAI(model="gpt-5-mini", streaming=True)
 
@tool
def get_weather(city: str) -> str:
    """Return a fixed weather forecast for this trip-planning demo."""
    return f"Demo forecast for {city}: sunny, 22 degrees Celsius."
 
tools = [get_weather]
 
def call_model(state: MessagesState) -> dict:
    response = llm.bind_tools(tools).invoke(state["messages"])
    return {"messages": [response]}
 
def should_continue(state: MessagesState) -> str:
    last_msg = state["messages"][-1]
    if last_msg.tool_calls:
        return "tools"
    return END
 
# Build the graph
builder = StateGraph(MessagesState)
builder.add_node("model", call_model)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "model")
builder.add_conditional_edges("model", should_continue)
builder.add_edge("tools", "model")
 
graph = builder.compile()

When the model requests get_weather, ToolNode executes the tool and adds its result to the graph's messages. The graph returns to the model for an answer. The adapter accumulates the streamed events into messages() and toolCalls(); your template reads their latest values. Register the compiled graph on your LangGraph server and point provideAgent() at its assistant id, as in the Quick Start.

Performance: Signals vs Alternatives

Keep computations cheap while a response is streaming. Signals and Observables can both work with OnPush; choose the representation that fits the API you consume.

ApproachTemplate updatesLifetime
Signals + OnPushAngular tracks Signal readsThe provider owns the agent; a consuming component does not dispose that shared agent
Observable + async pipeThe pipe marks its view when a value arrivesKeeps its subscription while the source and view remain; unsubscribes on source replacement or view destruction
Observable + manual subscribeApplication code arranges change notificationApplication code owns subscription teardown

An async pipe does not resubscribe on every token or template check. Profile the actual application before attributing rendering cost to a reactive primitive. Expensive Markdown rendering, layout work, or repeated derived calculations can matter more than the state read itself.

Tip: Signal equality checks

Treat the agent's arrays and objects as read-only. Mutating them in place bypasses the adapter's update path. Derive application views with computed() and use the agent's commands to change the conversation. A custom equality function should suppress an update only when the values are equivalent for every consumer.

What's Next

State Management

How LangGraph agent state flows into Angular Signals and how to structure complex state.

Streaming

Configure stream modes, handle token-by-token rendering, and manage concurrent streams.

LangGraph Basics

Understand the Python agent patterns that produce the events Signals consume.

API Reference

Full reference for every Signal, method, and option on LangGraphAgent.

Interrupts

Build human-in-the-loop approval flows that pause and resume the agent.

Looking for something specific?