`) |
+| `'math-inline'` | Inline math |
+| `'math-display'` | Display math block |
+| `'html-inline'` | Inline HTML node |
+| `'html-block'` | Block HTML node |
| `'link'` | Hyperlink (``) |
| `'autolink'` | Auto-detected URL or email link |
| `'image'` | Image (`
`) |
diff --git a/apps/website/content/docs/chat/guides/theming.mdx b/apps/website/content/docs/chat/guides/theming.mdx
index e4f6af13b..09dcb746f 100644
--- a/apps/website/content/docs/chat/guides/theming.mdx
+++ b/apps/website/content/docs/chat/guides/theming.mdx
@@ -135,5 +135,5 @@ If you previously customized `--chat-*` tokens, rename them to `--tplane-chat-*`
| `--chat-success` | `--tplane-chat-success` |
-The `CHAT_THEME_STYLES` named export no longer exists in `@threadplane/chat`. Remove imports of that constant - theme tokens are now applied automatically by each component. `CHAT_MARKDOWN_STYLES` remains available for custom markdown renderers.
+The `CHAT_THEME_STYLES` and `CHAT_MARKDOWN_STYLES` named exports do not exist in `@threadplane/chat`. Remove imports of those constants - theme tokens are applied automatically by each component, and custom markdown rendering should use `` or your own host styles around `renderMarkdown()`.
diff --git a/apps/website/content/docs/langgraph/api/inject-agent.mdx b/apps/website/content/docs/langgraph/api/inject-agent.mdx
index b18c26ffc..5ced855d8 100644
--- a/apps/website/content/docs/langgraph/api/inject-agent.mdx
+++ b/apps/website/content/docs/langgraph/api/inject-agent.mdx
@@ -31,6 +31,7 @@ injectAgent(ref: AgentRef): LangGraphAgent
Pass a typed ref handle created with `createAgentRef()` from `@threadplane/chat`. Returns a `LangGraphAgent` where `state()` and `value()` are typed as `T`. The same ref is passed to `provideAgent()` to bind the configuration.
```ts
+import { signal } from '@angular/core';
import { createAgentRef } from '@threadplane/chat';
import { injectAgent, provideAgent } from '@threadplane/langgraph';
import type { BaseMessage } from '@langchain/core/messages';
@@ -41,13 +42,17 @@ export interface MyState {
summary: string;
}
export const MY_AGENT = createAgentRef('my-agent');
+export const activeThreadId = signal(localStorage.getItem('threadId'));
// Register in app.config.ts providers:
provideAgent(MY_AGENT, {
apiUrl: 'http://localhost:2024',
assistantId: 'my-graph',
- threadId: () => localStorage.getItem('threadId') ?? undefined,
- onThreadId: (id) => localStorage.setItem('threadId', id),
+ threadId: activeThreadId,
+ onThreadId: (id) => {
+ activeThreadId.set(id);
+ localStorage.setItem('threadId', id);
+ },
});
// Inject in a component or service:
@@ -64,17 +69,22 @@ Pair it with `provideAgent()` at bootstrap to configure the API URL, assistant i
import { bootstrapApplication } from '@angular/platform-browser';
import { provideAgent } from '@threadplane/langgraph';
import { createAgentRef } from '@threadplane/chat';
+import { signal } from '@angular/core';
import { AppComponent } from './app/app.component';
export const MY_AGENT = createAgentRef('my-agent');
+export const activeThreadId = signal(localStorage.getItem('threadId'));
bootstrapApplication(AppComponent, {
providers: [
provideAgent(MY_AGENT, {
apiUrl: 'http://localhost:2024',
assistantId: 'my-graph',
- threadId: () => localStorage.getItem('threadId') ?? undefined,
- onThreadId: (id) => localStorage.setItem('threadId', id),
+ threadId: activeThreadId,
+ onThreadId: (id) => {
+ activeThreadId.set(id);
+ localStorage.setItem('threadId', id);
+ },
}),
],
});
diff --git a/apps/website/content/docs/langgraph/api/langgraph-threads-adapter.mdx b/apps/website/content/docs/langgraph/api/langgraph-threads-adapter.mdx
new file mode 100644
index 000000000..2e33590df
--- /dev/null
+++ b/apps/website/content/docs/langgraph/api/langgraph-threads-adapter.mdx
@@ -0,0 +1,70 @@
+# LangGraphThreadsAdapter
+
+SDK-backed thread CRUD for LangGraph Platform threads. Use it with chat thread components instead of hand-rolling local thread lists.
+
+## Configure
+
+```ts
+import {
+ LANGGRAPH_THREADS_CONFIG,
+ LangGraphThreadsAdapter,
+ refreshOnRunEnd,
+} from '@threadplane/langgraph';
+
+export const appConfig: ApplicationConfig = {
+ providers: [
+ {
+ provide: LANGGRAPH_THREADS_CONFIG,
+ useValue: { apiUrl: 'http://localhost:2024' },
+ },
+ LangGraphThreadsAdapter,
+ ],
+};
+```
+
+`LANGGRAPH_THREADS_CONFIG.apiUrl` accepts the same absolute or relative LangGraph API URLs as `provideAgent()`.
+
+## Pair with chat thread UI
+
+```ts
+import { Component, inject } from '@angular/core';
+import { ChatThreadListComponent, type ThreadActionAdapter } from '@threadplane/chat';
+import { injectAgent, LangGraphThreadsAdapter, refreshOnRunEnd } from '@threadplane/langgraph';
+
+@Component({
+ standalone: true,
+ imports: [ChatThreadListComponent],
+ template: `
+
+ `,
+})
+export class ThreadsPanel {
+ protected readonly agent = injectAgent();
+ protected readonly threads = inject(LangGraphThreadsAdapter);
+
+ protected readonly actions: ThreadActionAdapter = {
+ rename: (id, title) => this.threads.rename(id, title),
+ delete: (id) => this.threads.delete(id),
+ archive: (id) => this.threads.archive(id),
+ pin: (id) => this.threads.pin(id),
+ };
+
+ constructor() {
+ void this.threads.refresh();
+ refreshOnRunEnd(this.agent, () => this.threads.refresh());
+ }
+}
+```
+
+The adapter maps SDK threads to the `Thread` type consumed by `ChatThreadListComponent` and `ChatSidenavComponent`. It expects titles in `metadata.title`.
+
+## Shared SDK client
+
+Provide `LANGGRAPH_CLIENT` when you want to share an SDK `Client` instance or inject a test double. Provide `LANGGRAPH_CLIENT_OPTIONS` once at the app root to tune the SDK retry budget used by both `FetchStreamTransport` and the threads adapter.
+
+{/* Auto-rendered from api-docs.json — see page component */}
diff --git a/apps/website/content/docs/langgraph/getting-started/quickstart.mdx b/apps/website/content/docs/langgraph/getting-started/quickstart.mdx
index 7bc7aa114..84e957bf7 100644
--- a/apps/website/content/docs/langgraph/getting-started/quickstart.mdx
+++ b/apps/website/content/docs/langgraph/getting-started/quickstart.mdx
@@ -10,9 +10,11 @@ Angular 20+ project with Node.js 18+. If you need setup help, see the [Installat
```bash
-npm install @threadplane/langgraph
+npm install @threadplane/langgraph @threadplane/chat
```
+Most npm clients install peer dependencies automatically. Strict package managers may also ask you to install the Angular, LangChain, and LangGraph SDK peers listed in the [Installation](/docs/langgraph/getting-started/installation) guide.
+
diff --git a/apps/website/content/docs/licensing/getting-started/introduction.mdx b/apps/website/content/docs/licensing/getting-started/introduction.mdx
index a9f37e637..2b272cbbc 100644
--- a/apps/website/content/docs/licensing/getting-started/introduction.mdx
+++ b/apps/website/content/docs/licensing/getting-started/introduction.mdx
@@ -18,7 +18,7 @@ The main entry point exports:
| `inferNoncommercial()` | returns a default noncommercial hint from `NODE_ENV` |
| `LICENSE_PUBLIC_KEY` | bundled public key |
-This table covers the functions and the bundled key, not the full export list. The package also exports the supporting types — `LicenseClaims`, `LicenseTier`, `LicenseStatus`, `VerifyResult`/`VerifyReason`, `EvaluateResult`/`EvaluateOptions`, `RunLicenseCheckOptions`, and `EmitNagOptions`. See the [API reference](../reference/api.mdx) for the full type surface.
+This table covers the functions and the bundled key, not the full export list. The package also exports the supporting types — `LicenseClaims`, `LicenseTier`, `LicenseStatus`, `VerifyResult`/`VerifyReason`, `EvaluateResult`/`EvaluateOptions`, `RunLicenseCheckOptions`, and `EmitNagOptions`. See the [API reference](/docs/licensing/reference/api) for the full type surface.
`@noble/ed25519` is the only peer dependency.
@@ -73,6 +73,6 @@ The code returns statuses instead of throwing for normal license states. For me,
## Next steps
-- [Quickstart](./quickstart.mdx) — a runnable round-trip: sign, verify, and evaluate a token end to end.
-- [Setup](../guides/setup.mdx) — consume the check via `provideChat()` or call `runLicenseCheck()` directly.
-- [API reference](../reference/api.mdx) — the full type surface.
+- [Quickstart](/docs/licensing/getting-started/quickstart) — a runnable round-trip: sign, verify, and evaluate a token end to end.
+- [Setup](/docs/licensing/guides/setup) — consume the check via `provideChat()` or call `runLicenseCheck()` directly.
+- [API reference](/docs/licensing/reference/api) — the full type surface.
diff --git a/apps/website/content/docs/licensing/getting-started/quickstart.mdx b/apps/website/content/docs/licensing/getting-started/quickstart.mdx
index 2a35cc473..91a8ce2f1 100644
--- a/apps/website/content/docs/licensing/getting-started/quickstart.mdx
+++ b/apps/website/content/docs/licensing/getting-started/quickstart.mdx
@@ -57,6 +57,6 @@ console.log(
## Next steps
-- [Setup](../guides/setup.mdx) — consume the check via `provideChat()` or call `runLicenseCheck()` directly.
-- [CI and offline use](../guides/ci-and-offline.mdx) — verify and sign without network access.
-- [API reference](../reference/api.mdx) — the full type surface.
+- [Setup](/docs/licensing/guides/setup) — consume the check via `provideChat()` or call `runLicenseCheck()` directly.
+- [CI and offline use](/docs/licensing/guides/ci-and-offline) — verify and sign without network access.
+- [API reference](/docs/licensing/reference/api) — the full type surface.
diff --git a/apps/website/content/docs/licensing/guides/setup.mdx b/apps/website/content/docs/licensing/guides/setup.mdx
index feb382177..566dd5042 100644
--- a/apps/website/content/docs/licensing/guides/setup.mdx
+++ b/apps/website/content/docs/licensing/guides/setup.mdx
@@ -108,7 +108,7 @@ It's only a default hint. Callers can pass `isNoncommercial` explicitly.
## Gotchas
-`runLicenseCheck()` is idempotent for identical `package` and `token` values. A repeated call with the same key returns `licensed` without re-running the check.
+`runLicenseCheck()` is idempotent for identical `package` and `token` values. A repeated call with the same key returns the same status computed on the first call without re-running the check.
That keeps repeated provider initialization quiet, but it means package authors shouldn't use repeated calls with identical inputs to poll for status.
diff --git a/apps/website/content/docs/licensing/reference/api.mdx b/apps/website/content/docs/licensing/reference/api.mdx
index 8abe9a952..e5cbdc646 100644
--- a/apps/website/content/docs/licensing/reference/api.mdx
+++ b/apps/website/content/docs/licensing/reference/api.mdx
@@ -142,3 +142,13 @@ function signLicense(
```
Signs claims with an Ed25519 private key and returns the compact token consumed by `verifyLicense()`.
+
+## LICENSE_PUBLIC_KEY
+
+```ts
+const LICENSE_PUBLIC_KEY: Uint8Array;
+```
+
+The bundled Ed25519 public key used by Threadplane package checks. Use it when verifying a real issued license token.
+
+Use a throwaway public key only in tests or examples where you also create the matching private key with `signLicense()`.
diff --git a/apps/website/content/docs/middleware/api/api-docs.json b/apps/website/content/docs/middleware/api/api-docs.json
new file mode 100644
index 000000000..63fc67c38
--- /dev/null
+++ b/apps/website/content/docs/middleware/api/api-docs.json
@@ -0,0 +1,476 @@
+[
+ {
+ "name": "BaseMessage",
+ "kind": "interface",
+ "description": "Base class for all types of messages in a conversation. It includes\nproperties like `content`, `name`, and `additional_kwargs`. It also\nincludes methods like `toDict()` and `_getType()`.",
+ "properties": [
+ {
+ "name": "additional_kwargs",
+ "type": "object",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "content",
+ "type": "$InferMessageContent",
+ "description": "Array of content blocks that make up the message content",
+ "optional": false
+ },
+ {
+ "name": "id",
+ "type": "string",
+ "description": "Unique identifier for this message",
+ "optional": true
+ },
+ {
+ "name": "lc_kwargs",
+ "type": "SerializedFields",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "lc_namespace",
+ "type": "string[]",
+ "description": "A path to the module that contains the class, eg. [\"langchain\", \"llms\"]\nUsually should be the same as the entrypoint the class is exported from.",
+ "optional": false
+ },
+ {
+ "name": "lc_serializable",
+ "type": "boolean",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "name",
+ "type": "string",
+ "description": "",
+ "optional": true
+ },
+ {
+ "name": "response_metadata",
+ "type": "NonNullable[\"response_metadata\"]>",
+ "description": "Metadata about the message",
+ "optional": false
+ },
+ {
+ "name": "type",
+ "type": "TRole",
+ "description": "The message type/role",
+ "optional": false
+ },
+ {
+ "name": "_printableFields",
+ "type": "unknown",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "[toStringTag]",
+ "type": "unknown",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "contentBlocks",
+ "type": "unknown",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "lc_aliases",
+ "type": "unknown",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "lc_attributes",
+ "type": "unknown",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "lc_id",
+ "type": "unknown",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "lc_secrets",
+ "type": "unknown",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "lc_serializable_keys",
+ "type": "unknown",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "text",
+ "type": "unknown",
+ "description": "",
+ "optional": false
+ }
+ ],
+ "methods": [
+ {
+ "name": "_getType",
+ "signature": "_getType(): MessageType",
+ "description": "",
+ "params": []
+ },
+ {
+ "name": "_updateId",
+ "signature": "_updateId(value: string | undefined): void",
+ "description": "",
+ "params": [
+ {
+ "name": "value",
+ "type": "string | undefined",
+ "description": "",
+ "optional": false
+ }
+ ]
+ },
+ {
+ "name": "getType",
+ "signature": "getType(): MessageType",
+ "description": "",
+ "params": []
+ },
+ {
+ "name": "toDict",
+ "signature": "toDict(): StoredMessage",
+ "description": "",
+ "params": []
+ },
+ {
+ "name": "toFormattedString",
+ "signature": "toFormattedString(format: \"pretty\"): string",
+ "description": "",
+ "params": [
+ {
+ "name": "format",
+ "type": "\"pretty\"",
+ "description": "",
+ "optional": true
+ }
+ ]
+ },
+ {
+ "name": "toJSON",
+ "signature": "toJSON(): Serialized",
+ "description": "",
+ "params": []
+ },
+ {
+ "name": "toJSONNotImplemented",
+ "signature": "toJSONNotImplemented(): SerializedNotImplemented<>",
+ "description": "",
+ "params": []
+ }
+ ],
+ "examples": []
+ },
+ {
+ "name": "BindableModel",
+ "kind": "interface",
+ "description": "A chat model that can bind tools (the LangChain `Runnable.bindTools` surface).",
+ "properties": [],
+ "methods": [
+ {
+ "name": "bindTools",
+ "signature": "bindTools(tools: unknown[], kwargs: unknown): unknown",
+ "description": "",
+ "params": [
+ {
+ "name": "tools",
+ "type": "unknown[]",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "kwargs",
+ "type": "unknown",
+ "description": "",
+ "optional": true
+ }
+ ]
+ }
+ ],
+ "examples": []
+ },
+ {
+ "name": "ClientToolSpec",
+ "kind": "interface",
+ "description": "A frontend-declared client tool: name + description + JSON-Schema parameters.",
+ "properties": [
+ {
+ "name": "description",
+ "type": "string",
+ "description": "",
+ "optional": true
+ },
+ {
+ "name": "name",
+ "type": "string",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "parameters",
+ "type": "Record",
+ "description": "",
+ "optional": true
+ }
+ ],
+ "examples": []
+ },
+ {
+ "name": "ClientToolsState",
+ "kind": "interface",
+ "description": "The slice of graph state this middleware reads.",
+ "properties": [
+ {
+ "name": "client_tools",
+ "type": "ClientToolSpec[]",
+ "description": "Fallback channel — the raw run input key.",
+ "optional": true
+ },
+ {
+ "name": "messages",
+ "type": "BaseMessage, MessageType>[]",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "tools",
+ "type": "ClientToolSpec[]",
+ "description": "Primary channel — AG-UI/LangGraph merges RunAgentInput.tools here.",
+ "optional": true
+ }
+ ],
+ "examples": []
+ },
+ {
+ "name": "OpenAIFunctionTool",
+ "kind": "interface",
+ "description": "The explicit OpenAI function-tool shape accepted by ChatModel.bindTools across versions.",
+ "properties": [
+ {
+ "name": "function",
+ "type": "object",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "type",
+ "type": "\"function\"",
+ "description": "",
+ "optional": false
+ }
+ ],
+ "examples": []
+ },
+ {
+ "name": "bindClientTools",
+ "kind": "function",
+ "description": "Bind server tools + the client catalog stubs onto `llm`. Call this INSIDE the\nagent node (per-run) — the client catalog arrives in state and may differ per run.",
+ "signature": "bindClientTools(llm: M, serverTools: unknown[], state: ClientToolsState): ReturnType",
+ "params": [
+ {
+ "name": "llm",
+ "type": "M",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "serverTools",
+ "type": "unknown[]",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "state",
+ "type": "ClientToolsState",
+ "description": "",
+ "optional": false
+ }
+ ],
+ "returns": {
+ "type": "ReturnType",
+ "description": ""
+ },
+ "examples": []
+ },
+ {
+ "name": "clientToolNames",
+ "kind": "function",
+ "description": "The set of tool names declared by the client in this run.",
+ "signature": "clientToolNames(state: ClientToolsState): Set",
+ "params": [
+ {
+ "name": "state",
+ "type": "ClientToolsState",
+ "description": "",
+ "optional": false
+ }
+ ],
+ "returns": {
+ "type": "Set",
+ "description": ""
+ },
+ "examples": []
+ },
+ {
+ "name": "clientToolsChannel",
+ "kind": "function",
+ "description": "State channels for the client-tools catalog. Spread into Annotation.Root so a graph\ndeclares the `tools` (primary) and `client_tools` (fallback) slices in one line:\n\n const State = Annotation.Root({ ...MessagesAnnotation.spec, ...clientToolsChannel() });\n\nBoth are last-value-wins channels (the catalog is replaced per run, not accumulated).",
+ "signature": "clientToolsChannel(): object",
+ "params": [],
+ "returns": {
+ "type": "object",
+ "description": ""
+ },
+ "examples": []
+ },
+ {
+ "name": "clientToolSpecs",
+ "kind": "function",
+ "description": "The client catalog as OpenAI function-tool dicts for `model.bindTools`.",
+ "signature": "clientToolSpecs(state: ClientToolsState): OpenAIFunctionTool[]",
+ "params": [
+ {
+ "name": "state",
+ "type": "ClientToolsState",
+ "description": "",
+ "optional": false
+ }
+ ],
+ "returns": {
+ "type": "OpenAIFunctionTool[]",
+ "description": ""
+ },
+ "examples": []
+ },
+ {
+ "name": "clientToolsRouter",
+ "kind": "function",
+ "description": "A prebuilt conditional-edge callback. serverToolNames is bound once at construction;\nthe returned function takes only state.\n\n graph.addConditionalEdges('agent', clientToolsRouter([]), ['tools', END]);",
+ "signature": "clientToolsRouter(serverToolNames: Iterable, opts: object): (state: ClientToolsState) => string",
+ "params": [
+ {
+ "name": "serverToolNames",
+ "type": "Iterable",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "opts",
+ "type": "object",
+ "description": "",
+ "optional": true
+ }
+ ],
+ "returns": {
+ "type": "(state: ClientToolsState) => string",
+ "description": ""
+ },
+ "examples": []
+ },
+ {
+ "name": "hasClientToolCall",
+ "kind": "function",
+ "description": "True if the last message calls at least one client tool.",
+ "signature": "hasClientToolCall(state: ClientToolsState): boolean",
+ "params": [
+ {
+ "name": "state",
+ "type": "ClientToolsState",
+ "description": "",
+ "optional": false
+ }
+ ],
+ "returns": {
+ "type": "boolean",
+ "description": ""
+ },
+ "examples": []
+ },
+ {
+ "name": "hasServerToolCall",
+ "kind": "function",
+ "description": "True if the last message calls at least one server (non-client) tool.\nA call is server-side when its name is in serverToolNames OR is not a known\nclient tool (unknown tools are assumed server-side).",
+ "signature": "hasServerToolCall(state: ClientToolsState, serverToolNames: Iterable): boolean",
+ "params": [
+ {
+ "name": "state",
+ "type": "ClientToolsState",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "serverToolNames",
+ "type": "Iterable",
+ "description": "",
+ "optional": false
+ }
+ ],
+ "returns": {
+ "type": "boolean",
+ "description": ""
+ },
+ "examples": []
+ },
+ {
+ "name": "lastMessage",
+ "kind": "function",
+ "description": "The last message from state.messages, or undefined.",
+ "signature": "lastMessage(state: ClientToolsState): BaseMessage, MessageType> | undefined",
+ "params": [
+ {
+ "name": "state",
+ "type": "ClientToolsState",
+ "description": "",
+ "optional": false
+ }
+ ],
+ "returns": {
+ "type": "BaseMessage, MessageType> | undefined",
+ "description": ""
+ },
+ "examples": []
+ },
+ {
+ "name": "routeAfterAgent",
+ "kind": "function",
+ "description": "Routing helper for a LangGraph conditional edge. Returns `toolsNode` when the last\nmessage has a server tool call (dispatch to the server ToolNode); otherwise `end`\n(client-only calls — the browser executes them — and no-tool-call turns both end).",
+ "signature": "routeAfterAgent(state: ClientToolsState, serverToolNames: Iterable, opts: object): string",
+ "params": [
+ {
+ "name": "state",
+ "type": "ClientToolsState",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "serverToolNames",
+ "type": "Iterable",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "opts",
+ "type": "object",
+ "description": "",
+ "optional": true
+ }
+ ],
+ "returns": {
+ "type": "string",
+ "description": ""
+ },
+ "examples": []
+ }
+]
\ No newline at end of file
diff --git a/apps/website/content/docs/middleware/api/client-tool-helpers.mdx b/apps/website/content/docs/middleware/api/client-tool-helpers.mdx
new file mode 100644
index 000000000..e1060948d
--- /dev/null
+++ b/apps/website/content/docs/middleware/api/client-tool-helpers.mdx
@@ -0,0 +1,17 @@
+# bindClientTools()
+
+`bindClientTools()` is the main helper from `@threadplane/middleware/langgraph`. It binds your server tools plus the current run's client-tool catalog onto a LangChain chat model.
+
+```ts
+import {
+ bindClientTools,
+ clientToolsChannel,
+ clientToolsRouter,
+} from '@threadplane/middleware/langgraph';
+```
+
+There is no root JavaScript entry point for `@threadplane/middleware`; import from the `/langgraph` subpath.
+
+Related helpers in the same entry point include `clientToolsChannel()`, `clientToolsRouter()`, `clientToolSpecs()`, `clientToolNames()`, `hasClientToolCall()`, `hasServerToolCall()`, `routeAfterAgent()`, and `lastMessage()`.
+
+{/* Auto-rendered from api-docs.json — see page component */}
diff --git a/apps/website/content/docs/middleware/getting-started/introduction.mdx b/apps/website/content/docs/middleware/getting-started/introduction.mdx
new file mode 100644
index 000000000..a5c0637f0
--- /dev/null
+++ b/apps/website/content/docs/middleware/getting-started/introduction.mdx
@@ -0,0 +1,59 @@
+# Introduction
+
+`@threadplane/middleware` is the backend companion for Threadplane client tools. It lets a browser declare tools, lets the model call those tools, and routes client-tool-only turns back to the browser for execution.
+
+The package currently publishes one runtime entry point:
+
+```ts
+import {
+ bindClientTools,
+ clientToolsChannel,
+ clientToolsRouter,
+} from '@threadplane/middleware/langgraph';
+```
+
+There is no root `@threadplane/middleware` JavaScript entry point. Import from `@threadplane/middleware/langgraph`.
+
+## What it does
+
+The LangGraph entry point reads a client tool catalog from graph state, converts it into OpenAI function-tool objects, binds those tool stubs onto your chat model, and routes client-tool calls to `END` so the browser can execute them.
+
+The catalog is read from `state.tools` first. If that channel is absent or empty, it falls back to `state.client_tools`.
+
+## Runtime flow
+
+1. The browser sends tool specs with the run request.
+2. Your LangGraph node calls `bindClientTools()` inside the run, because the catalog can differ per request.
+3. The model emits a tool call for a browser-declared tool.
+4. `clientToolsRouter()` routes client-only tool calls to `END`.
+5. The browser executes the local tool and resumes the graph with a `ToolMessage`.
+
+If a turn mixes server tool calls and client tool calls, server tools win the first route. The server tool node runs first, and the client call can surface on a later turn.
+
+## Public surface
+
+The entry point exports:
+
+| API | Purpose |
+|-----|---------|
+| `clientToolsChannel()` | Adds the `tools` and `client_tools` state channels to a LangGraph annotation. |
+| `bindClientTools()` | Binds server tools plus client-declared tool stubs onto a model. |
+| `clientToolsRouter()` | Creates a conditional-edge router for server-tool vs client-tool routing. |
+| `clientToolSpecs()` | Converts state catalog entries into OpenAI function-tool specs. |
+| `clientToolNames()` | Returns the set of client-declared tool names for a run. |
+| `hasClientToolCall()` | Checks whether the last message calls a client tool. |
+| `hasServerToolCall()` | Checks whether the last message calls a server or unknown tool. |
+| `routeAfterAgent()` | Lower-level routing helper used by `clientToolsRouter()`. |
+| `lastMessage()` | Reads the last message from state. |
+
+## When to use it
+
+Use middleware when you own a LangGraph.js backend and want browser-declared tools from `@threadplane/chat` to participate in model tool calling without executing browser-only code on the server.
+
+If your backend already speaks AG-UI, use `@threadplane/ag-ui` instead. If your frontend talks directly to LangGraph and does not need browser-executed tools, `@threadplane/langgraph` can run without this middleware.
+
+## Next steps
+
+- [Quick Start](/docs/middleware/getting-started/quickstart) - install and wire the LangGraph helper.
+- [LangGraph Client Tools](/docs/middleware/guides/langgraph-client-tools) - routing details and server-tool behavior.
+- [Client Tool Helpers](/docs/middleware/api/client-tool-helpers) - generated API reference for the exported helpers.
diff --git a/apps/website/content/docs/middleware/getting-started/quickstart.mdx b/apps/website/content/docs/middleware/getting-started/quickstart.mdx
new file mode 100644
index 000000000..ecd794d8f
--- /dev/null
+++ b/apps/website/content/docs/middleware/getting-started/quickstart.mdx
@@ -0,0 +1,98 @@
+# Quick Start
+
+Install the middleware package and its LangGraph peer dependencies:
+
+```bash
+npm install @threadplane/middleware @langchain/core @langchain/langgraph
+```
+
+The package exposes its JavaScript API from `@threadplane/middleware/langgraph`.
+
+## Add client-tool state channels
+
+```ts
+import { Annotation, END, MessagesAnnotation, StateGraph } from '@langchain/langgraph';
+import {
+ bindClientTools,
+ clientToolsChannel,
+ clientToolsRouter,
+} from '@threadplane/middleware/langgraph';
+
+const State = Annotation.Root({
+ ...MessagesAnnotation.spec,
+ ...clientToolsChannel(),
+});
+```
+
+`clientToolsChannel()` adds both `tools` and `client_tools`. The middleware reads `tools` first and falls back to `client_tools`.
+
+## Bind tools per run
+
+Call `bindClientTools()` inside the graph node, not once at module load. The browser sends the catalog with each run, so the tool list is request-scoped.
+
+```ts
+const SERVER_TOOLS: unknown[] = [];
+const serverToolNames: string[] = [];
+
+async function agent(state: typeof State.State) {
+ const llm = bindClientTools(baseLlm, SERVER_TOOLS, state);
+ const response = await llm.invoke(state.messages);
+ return { messages: [response] };
+}
+```
+
+`SERVER_TOOLS` is where your server-owned LangChain tools go. Client tools from state are appended as model-visible function stubs.
+
+## Route after the agent
+
+```ts
+const graph = new StateGraph(State)
+ .addNode('agent', agent)
+ .addEdge('__start__', 'agent')
+ .addConditionalEdges('agent', clientToolsRouter(serverToolNames), ['tools', END])
+ .compile();
+```
+
+When the last model message calls a server tool, the router returns the server tools node. When the last model message calls only browser-declared client tools, the router returns `END` so the frontend can execute the call and resume.
+
+## Complete skeleton
+
+```ts
+import { Annotation, END, MessagesAnnotation, StateGraph } from '@langchain/langgraph';
+import { ChatOpenAI } from '@langchain/openai';
+import {
+ bindClientTools,
+ clientToolsChannel,
+ clientToolsRouter,
+} from '@threadplane/middleware/langgraph';
+
+const State = Annotation.Root({
+ ...MessagesAnnotation.spec,
+ ...clientToolsChannel(),
+});
+
+const baseLlm = new ChatOpenAI({ model: 'gpt-4o-mini' });
+const serverTools: unknown[] = [];
+const serverToolNames: string[] = [];
+
+async function agent(state: typeof State.State) {
+ const llm = bindClientTools(baseLlm, serverTools, state);
+ const response = await llm.invoke(state.messages);
+ return { messages: [response] };
+}
+
+export const graph = new StateGraph(State)
+ .addNode('agent', agent)
+ .addEdge('__start__', 'agent')
+ .addConditionalEdges('agent', clientToolsRouter(serverToolNames), ['tools', END])
+ .compile();
+```
+
+## Frontend pairing
+
+On the frontend, declare client tools with `@threadplane/chat` and send them through an adapter that forwards the tool specs into the run input. The middleware consumes those specs on the backend; the browser remains responsible for executing the actual local function, view, or ask tool.
+
+## Next steps
+
+- [LangGraph Client Tools](/docs/middleware/guides/langgraph-client-tools) - mixed server/client routing and helper behavior.
+- [Client Tool Helpers](/docs/middleware/api/client-tool-helpers) - generated API details.
diff --git a/apps/website/content/docs/middleware/guides/langgraph-client-tools.mdx b/apps/website/content/docs/middleware/guides/langgraph-client-tools.mdx
new file mode 100644
index 000000000..8cc2df911
--- /dev/null
+++ b/apps/website/content/docs/middleware/guides/langgraph-client-tools.mdx
@@ -0,0 +1,99 @@
+# LangGraph Client Tools
+
+Client tools are frontend-declared tools that the model can call but the browser executes. The backend only exposes tool schemas to the model and decides whether a turn should continue on the server or end so the client can run a local tool.
+
+## State shape
+
+The middleware reads this state slice:
+
+```ts
+interface ClientToolsState {
+ messages: BaseMessage[];
+ tools?: ClientToolSpec[];
+ client_tools?: ClientToolSpec[];
+}
+```
+
+Each `ClientToolSpec` is:
+
+```ts
+interface ClientToolSpec {
+ name: string;
+ description?: string;
+ parameters?: Record;
+}
+```
+
+`tools` is the primary channel. `client_tools` is a fallback for raw run input keys.
+
+## Catalog conversion
+
+`clientToolSpecs(state)` filters out nameless entries and converts each client tool into the OpenAI function-tool shape accepted by LangChain chat models:
+
+```ts
+{
+ type: 'function',
+ function: {
+ name: spec.name,
+ description: spec.description ?? '',
+ parameters: spec.parameters ?? {},
+ },
+}
+```
+
+`bindClientTools(llm, serverTools, state)` binds server tools first, then appends the client-tool stubs for the current run.
+
+## Routing behavior
+
+`clientToolsRouter(serverToolNames)` creates a conditional-edge router.
+
+The router sends the graph to the server tool node when the last model message includes a server tool call. A call is treated as server-side when its name appears in `serverToolNames` or when the name is unknown to the current client catalog.
+
+The router returns `END` when the last model message has only known client-tool calls or no tool calls.
+
+```ts
+.addConditionalEdges('agent', clientToolsRouter(['lookupOrder']), ['tools', END])
+```
+
+Use `serverToolNames` to disambiguate tools you actually execute on the backend.
+
+## Mixed tool calls
+
+When a model message contains both server and client tool calls, server work runs first. This keeps backend-owned side effects on the backend and lets the client tool call surface after the graph resumes.
+
+That behavior comes from `hasServerToolCall()`: any server or unknown tool call routes to the server tool node.
+
+## Lower-level helpers
+
+Use these helpers when you need custom routing:
+
+```ts
+import {
+ clientToolNames,
+ clientToolSpecs,
+ hasClientToolCall,
+ hasServerToolCall,
+ lastMessage,
+ routeAfterAgent,
+} from '@threadplane/middleware/langgraph';
+```
+
+`routeAfterAgent(state, serverToolNames, opts)` is the primitive behind `clientToolsRouter()`. The default destinations are `'tools'` and `'__end__'`, but you can override them:
+
+```ts
+routeAfterAgent(state, ['lookupOrder'], {
+ toolsNode: 'serverTools',
+ end: '__end__',
+});
+```
+
+## Frontend contract
+
+The backend middleware does not execute browser tools. The frontend must:
+
+1. send the catalog into the run input;
+2. observe the model's tool call;
+3. execute the local tool in the browser;
+4. resume the graph with a `ToolMessage` containing the result.
+
+The `@threadplane/chat` client-tools helpers provide the browser-side declaration and execution pieces.
diff --git a/apps/website/content/docs/render/api/define-angular-registry.mdx b/apps/website/content/docs/render/api/define-angular-registry.mdx
index ba845af4c..d131595c2 100644
--- a/apps/website/content/docs/render/api/define-angular-registry.mdx
+++ b/apps/website/content/docs/render/api/define-angular-registry.mdx
@@ -28,10 +28,12 @@ function defineAngularRegistry(
interface RenderViewEntry {
component: AngularComponentRenderer;
fallback?: AngularComponentRenderer;
+ schema?: StandardSchemaV1;
+ description?: string;
}
```
-Use the object form to configure a custom per-entry fallback (see [Per-Component Fallbacks](#per-component-fallbacks) below). A bare component class is shorthand for `{ component }` paired with the library's default fallback.
+Use the object form to configure a custom per-entry fallback, a Standard Schema validator, or a description for higher-level tooling. A bare component class is shorthand for `{ component }` paired with the library's default fallback.
### Returns
@@ -130,6 +132,29 @@ registry.getEntry('Missing'); // undefined (not registered)
An entry that omits `fallback` -- including every bare-component entry like `Text` above -- falls back to the library's `DefaultFallbackComponent`. Once the real component mounts, the choice is monotonic for that element instance: a later prop resolving to `undefined` never reverts it to the fallback.
+### Schema-Gated Mounting
+
+Add `schema` when a component should mount only after resolved props satisfy a Standard Schema validator such as Zod:
+
+```typescript
+import { z } from 'zod';
+import { defineAngularRegistry } from '@threadplane/render';
+
+const registry = defineAngularRegistry({
+ WeatherCard: {
+ component: WeatherCardComponent,
+ fallback: WeatherCardSkeletonComponent,
+ schema: z.object({
+ city: z.string(),
+ temperature: z.number(),
+ }),
+ description: 'Shows current weather for a city.',
+ },
+});
+```
+
+While props are missing or fail validation, the fallback renders. Once the props pass validation and the component mounts, later updates do not move that element back to the fallback.
+
## Internal Behavior
The function normalizes each input entry into a `NormalizedEntry` (`{ component, fallback, schema?, description? }`) and stores them in an internal `Map` for O(1) lookups. A bare component class is paired with `DefaultFallbackComponent`; an object entry keeps its own `fallback` (or the default) and preserves any `schema`/`description`:
diff --git a/apps/website/content/docs/render/guides/events.mdx b/apps/website/content/docs/render/guides/events.mdx
index f9f8f5f95..f2d006819 100644
--- a/apps/website/content/docs/render/guides/events.mdx
+++ b/apps/website/content/docs/render/guides/events.mdx
@@ -269,19 +269,23 @@ onEvent(event: RenderEvent) {
case 'lifecycle':
console.log('lifecycle:', event.event, event.scope, event.elementType);
break;
+ case 'result':
+ console.log('component result:', event.elementKey, event.value);
+ break;
}
}
```
-`RenderEvent` is a discriminated union of three variants, keyed by `type`:
+`RenderEvent` is a discriminated union of four variants, keyed by `type`:
| `type` | Interface | Fires when | Notable fields |
|--------|-----------|------------|----------------|
| `'handler'` | `RenderHandlerEvent` | A handler finishes running | `action`, `params`, `result?` |
| `'stateChange'` | `RenderStateChangeEvent` | The store value changes | `path`, `value`, `snapshot` |
| `'lifecycle'` | `RenderLifecycleEvent` | A spec or element mounts/destroys | `event` (`'mounted'` \| `'destroyed'`), `scope` (`'spec'` \| `'element'`), `elementKey?`, `elementType?` |
+| `'result'` | `RenderResultEvent` | A mounted view component calls `injectRenderHost().result(value)` | `value`, `elementKey?` |
-All three interfaces are exported from `@threadplane/render`. This output is the single source the [Lifecycle guide](/docs/render/guides/lifecycle) builds its `RENDER_LIFECYCLE` signals on top of -- both observe the same stream, so there's no double-counting.
+All four interfaces are exported from `@threadplane/render`. This output is the single source the [Lifecycle guide](/docs/render/guides/lifecycle) builds its `RENDER_LIFECYCLE` signals on top of -- both observe the same stream, so there's no double-counting.
## Next Steps
diff --git a/apps/website/content/docs/render/guides/registry.mdx b/apps/website/content/docs/render/guides/registry.mdx
index c0b7731f5..e0fd68eaf 100644
--- a/apps/website/content/docs/render/guides/registry.mdx
+++ b/apps/website/content/docs/render/guides/registry.mdx
@@ -35,7 +35,7 @@ uiRegistry.names(); // ['Text', 'Card', 'Button', 'Container
### Fallback Rendering
-When an element's type isn't registered, it renders that type's configured fallback if one exists, and otherwise renders nothing. Fallbacks also fill a transient gap during rendering: while an element's state-bound props are still resolving, the library can render a fallback to give visual feedback until the real component is ready. Once the real component mounts, it stays mounted -- later re-renders never revert to the fallback.
+Fallbacks belong to registered entries. When an element's type is not registered, there is no entry to read and the element renders nothing. When the type is registered, the entry's configured fallback can fill a transient gap while state-bound props are still resolving. Once the real component mounts, it stays mounted -- later re-renders never revert to the fallback.
## The Component Input Contract
@@ -161,6 +161,34 @@ export class InputComponent {
}
```
+## RenderHost
+
+Inside a mounted view component, `injectRenderHost()` exposes the element-scoped host:
+
+```typescript
+import { Component, inject } from '@angular/core';
+import { injectRenderHost } from '@threadplane/render';
+
+@Component({
+ selector: 'app-approval',
+ standalone: true,
+ template: `
+
+ `,
+})
+export class ApprovalComponent {
+ private readonly host = injectRenderHost();
+
+ approve() {
+ this.host.set('/approved', true);
+ this.host.emit('approved');
+ this.host.result({ approved: true });
+ }
+}
+```
+
+Use `set(path, value)` for state writes, `emit(event, payload?)` for spec `on` handlers, and `result(value)` when the component has produced a value that the host should observe as a `RenderResultEvent`.
+
## Recursive Children
Container components can render their children by using the `childKeys` and `spec` inputs with `RenderElementComponent`:
diff --git a/apps/website/content/docs/telemetry/api/api-docs.json b/apps/website/content/docs/telemetry/api/api-docs.json
index 0c63b4e87..f47e20f7b 100644
--- a/apps/website/content/docs/telemetry/api/api-docs.json
+++ b/apps/website/content/docs/telemetry/api/api-docs.json
@@ -1,4 +1,368 @@
[
+ {
+ "name": "ThreadplaneTelemetryService",
+ "kind": "class",
+ "description": "",
+ "params": [],
+ "examples": [],
+ "properties": [],
+ "methods": [
+ {
+ "name": "capture",
+ "signature": "capture(event: ThreadplaneTelemetryEvent, properties: Record): Promise",
+ "description": "",
+ "params": [
+ {
+ "name": "event",
+ "type": "ThreadplaneTelemetryEvent",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "properties",
+ "type": "Record",
+ "description": "",
+ "optional": true
+ }
+ ]
+ },
+ {
+ "name": "captureRuntimeInstanceCreated",
+ "signature": "captureRuntimeInstanceCreated(input: ThreadplaneBrowserRuntimeTelemetry): Promise",
+ "description": "",
+ "params": [
+ {
+ "name": "input",
+ "type": "ThreadplaneBrowserRuntimeTelemetry",
+ "description": "",
+ "optional": false
+ }
+ ]
+ },
+ {
+ "name": "captureRuntimeRequestCreated",
+ "signature": "captureRuntimeRequestCreated(input: ThreadplaneBrowserRuntimeTelemetry & { requestType: string }): Promise",
+ "description": "",
+ "params": [
+ {
+ "name": "input",
+ "type": "ThreadplaneBrowserRuntimeTelemetry & { requestType: string }",
+ "description": "",
+ "optional": false
+ }
+ ]
+ },
+ {
+ "name": "captureStreamEnded",
+ "signature": "captureStreamEnded(input: ThreadplaneBrowserStreamTelemetry): Promise",
+ "description": "",
+ "params": [
+ {
+ "name": "input",
+ "type": "ThreadplaneBrowserStreamTelemetry",
+ "description": "",
+ "optional": false
+ }
+ ]
+ },
+ {
+ "name": "captureStreamErrored",
+ "signature": "captureStreamErrored(input: ThreadplaneBrowserStreamErrorTelemetry): Promise",
+ "description": "",
+ "params": [
+ {
+ "name": "input",
+ "type": "ThreadplaneBrowserStreamErrorTelemetry",
+ "description": "",
+ "optional": false
+ }
+ ]
+ },
+ {
+ "name": "captureStreamStarted",
+ "signature": "captureStreamStarted(input: ThreadplaneBrowserStreamTelemetry): Promise",
+ "description": "",
+ "params": [
+ {
+ "name": "input",
+ "type": "ThreadplaneBrowserStreamTelemetry",
+ "description": "",
+ "optional": false
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "ThreadplaneBrowserRuntimeTelemetry",
+ "kind": "interface",
+ "description": "",
+ "properties": [
+ {
+ "name": "model",
+ "type": "string",
+ "description": "",
+ "optional": true
+ },
+ {
+ "name": "provider",
+ "type": "string",
+ "description": "",
+ "optional": true
+ },
+ {
+ "name": "surface",
+ "type": "string",
+ "description": "",
+ "optional": true
+ },
+ {
+ "name": "transport",
+ "type": "string",
+ "description": "",
+ "optional": false
+ }
+ ],
+ "examples": []
+ },
+ {
+ "name": "ThreadplaneBrowserStreamErrorTelemetry",
+ "kind": "interface",
+ "description": "",
+ "properties": [
+ {
+ "name": "durationMs",
+ "type": "number",
+ "description": "",
+ "optional": true
+ },
+ {
+ "name": "error",
+ "type": "unknown",
+ "description": "",
+ "optional": true
+ },
+ {
+ "name": "model",
+ "type": "string",
+ "description": "",
+ "optional": true
+ },
+ {
+ "name": "provider",
+ "type": "string",
+ "description": "",
+ "optional": true
+ },
+ {
+ "name": "surface",
+ "type": "string",
+ "description": "",
+ "optional": true
+ },
+ {
+ "name": "transport",
+ "type": "string",
+ "description": "",
+ "optional": false
+ }
+ ],
+ "examples": []
+ },
+ {
+ "name": "ThreadplaneBrowserStreamTelemetry",
+ "kind": "interface",
+ "description": "",
+ "properties": [
+ {
+ "name": "durationMs",
+ "type": "number",
+ "description": "",
+ "optional": true
+ },
+ {
+ "name": "model",
+ "type": "string",
+ "description": "",
+ "optional": true
+ },
+ {
+ "name": "provider",
+ "type": "string",
+ "description": "",
+ "optional": true
+ },
+ {
+ "name": "surface",
+ "type": "string",
+ "description": "",
+ "optional": true
+ },
+ {
+ "name": "transport",
+ "type": "string",
+ "description": "",
+ "optional": false
+ }
+ ],
+ "examples": []
+ },
+ {
+ "name": "ThreadplaneTelemetryConfig",
+ "kind": "interface",
+ "description": "",
+ "properties": [
+ {
+ "name": "enabled",
+ "type": "boolean",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "endpoint",
+ "type": "string",
+ "description": "Preferred app-owned ingest URL. The browser service POSTs neutral event\npayloads here; the endpoint decides where they ultimately go.",
+ "optional": true
+ },
+ {
+ "name": "posthogHost",
+ "type": "string",
+ "description": "",
+ "optional": true
+ },
+ {
+ "name": "posthogKey",
+ "type": "string",
+ "description": "",
+ "optional": true
+ },
+ {
+ "name": "sampleRate",
+ "type": "number",
+ "description": "",
+ "optional": true
+ },
+ {
+ "name": "sink",
+ "type": "ThreadplaneTelemetrySink",
+ "description": "Preferred app-owned delivery hook. Use this when the consuming app wants\nto forward events through its own analytics boundary.",
+ "optional": true
+ }
+ ],
+ "examples": []
+ },
+ {
+ "name": "ThreadplaneTelemetryEventPayload",
+ "kind": "interface",
+ "description": "",
+ "properties": [
+ {
+ "name": "event",
+ "type": "ThreadplaneTelemetryEvent",
+ "description": "",
+ "optional": false
+ },
+ {
+ "name": "properties",
+ "type": "Record",
+ "description": "",
+ "optional": true
+ }
+ ],
+ "examples": []
+ },
+ {
+ "name": "CaptureConfig",
+ "kind": "type",
+ "description": "",
+ "signature": "unknown",
+ "examples": []
+ },
+ {
+ "name": "ThreadplaneBrowserEvent",
+ "kind": "type",
+ "description": "",
+ "signature": "ThreadplaneTelemetryEvent",
+ "examples": []
+ },
+ {
+ "name": "ThreadplaneTelemetryEvent",
+ "kind": "type",
+ "description": "",
+ "signature": "\"tplane:browser_provided\" | \"tplane:browser_chat_init\" | \"tplane:runtime_instance_created\" | \"tplane:runtime_request_created\" | \"tplane:stream_started\" | \"tplane:stream_ended\" | \"tplane:stream_errored\"",
+ "examples": []
+ },
+ {
+ "name": "ThreadplaneTelemetrySink",
+ "kind": "type",
+ "description": "",
+ "signature": "(payload: ThreadplaneTelemetryEventPayload) => void | Promise",
+ "examples": []
+ },
+ {
+ "name": "THREADPLANE_TELEMETRY_CONFIG",
+ "kind": "const",
+ "description": "",
+ "signature": "InjectionToken",
+ "examples": []
+ },
+ {
+ "name": "isLocalAnalyticsHost",
+ "kind": "function",
+ "description": "",
+ "signature": "isLocalAnalyticsHost(host: unknown): boolean",
+ "params": [
+ {
+ "name": "host",
+ "type": "unknown",
+ "description": "",
+ "optional": false
+ }
+ ],
+ "returns": {
+ "type": "boolean",
+ "description": ""
+ },
+ "examples": []
+ },
+ {
+ "name": "provideThreadplaneTelemetry",
+ "kind": "function",
+ "description": "",
+ "signature": "provideThreadplaneTelemetry(config: ThreadplaneTelemetryConfig): EnvironmentProviders",
+ "params": [
+ {
+ "name": "config",
+ "type": "ThreadplaneTelemetryConfig",
+ "description": "",
+ "optional": false
+ }
+ ],
+ "returns": {
+ "type": "EnvironmentProviders",
+ "description": ""
+ },
+ "examples": []
+ },
+ {
+ "name": "shouldCaptureAnalytics",
+ "kind": "function",
+ "description": "",
+ "signature": "shouldCaptureAnalytics(__namedParameters: CaptureConfig): boolean",
+ "params": [
+ {
+ "name": "__namedParameters",
+ "type": "CaptureConfig",
+ "description": "",
+ "optional": false
+ }
+ ],
+ "returns": {
+ "type": "boolean",
+ "description": ""
+ },
+ "examples": []
+ },
{
"name": "ThreadplaneBrowserEvent",
"kind": "type",
diff --git a/apps/website/content/docs/telemetry/getting-started/installation.mdx b/apps/website/content/docs/telemetry/getting-started/installation.mdx
new file mode 100644
index 000000000..3dfc7c5be
--- /dev/null
+++ b/apps/website/content/docs/telemetry/getting-started/installation.mdx
@@ -0,0 +1,66 @@
+# Installation
+
+Install the telemetry package:
+
+```bash
+npm install @threadplane/telemetry
+```
+
+The package publishes separate entry points for browser, Node, and shared utilities:
+
+```ts
+import { provideThreadplaneTelemetry } from '@threadplane/telemetry/browser';
+import { captureEvent, disableTelemetry } from '@threadplane/telemetry/node';
+import { isTelemetryDisabled } from '@threadplane/telemetry';
+```
+
+## Optional peers
+
+`@angular/core` and `posthog-js` are optional peer dependencies.
+
+Install `@angular/core` when you use the browser Angular provider and service:
+
+```bash
+npm install @threadplane/telemetry @angular/core
+```
+
+Install `posthog-js` only when you use direct browser PostHog delivery through `posthogKey`. New integrations should prefer `sink` or `endpoint`, because those keep delivery inside your application's analytics boundary.
+
+```bash
+npm install @threadplane/telemetry posthog-js
+```
+
+## Browser entry point
+
+Browser telemetry is opt-in. Calling `provideThreadplaneTelemetry({ enabled: true, ... })` is what activates capture.
+
+```ts
+import { provideThreadplaneTelemetry } from '@threadplane/telemetry/browser';
+
+provideThreadplaneTelemetry({
+ enabled: true,
+ endpoint: '/api/telemetry',
+});
+```
+
+## Node entry point
+
+Node helpers live under `@threadplane/telemetry/node`.
+
+```ts
+import { captureRuntimeInstanceCreated } from '@threadplane/telemetry/node';
+
+await captureRuntimeInstanceCreated({
+ transport: 'langgraph',
+ provider: 'openai',
+ model: 'gpt-4.1',
+});
+```
+
+Node telemetry respects the environment opt-out variables documented in [Privacy and Opt-Out](/docs/telemetry/guides/privacy-and-opt-out).
+
+## Next steps
+
+- [Browser Telemetry](/docs/telemetry/guides/browser) - configure an Angular provider and runtime sink.
+- [Node Telemetry](/docs/telemetry/guides/node) - capture helpers for lifecycle and stream events.
+- [Events](/docs/telemetry/reference/events) - event names and payload shapes.
diff --git a/apps/website/content/prompts/configuration.md b/apps/website/content/prompts/configuration.md
index 37a2283a9..ed5d4a960 100644
--- a/apps/website/content/prompts/configuration.md
+++ b/apps/website/content/prompts/configuration.md
@@ -1,13 +1,13 @@
Configure angular globally and per-component in my Angular application.
-Global config (applies to all agent() calls in the app):
-In app.config.ts, provideAgent({ apiUrl: 'https://my-langgraph-server.com', }) — import provideAgent from '@threadplane/langgraph'.
+Global config:
+In app.config.ts, call provideAgent({ apiUrl: 'https://my-langgraph-server.com', assistantId: 'my-agent' }) in the providers array. Import provideAgent from '@threadplane/langgraph'.
-Per-call override (overrides global config for one component):
-Pass apiUrl directly to agent({ apiUrl: 'https://other-server.com', assistantId: 'my-agent' }) — per-call options take precedence over global config.
+Scoped override for one component subtree:
+Add providers: [provideAgent({ apiUrl: 'https://other-server.com', assistantId: 'other-agent' })] on that component. Components under that subtree call injectAgent() and receive the scoped singleton.
Custom transport (for auth headers, logging, or testing):
-Implement the AgentTransport interface from @threadplane/langgraph. Its required method is stream(assistantId, threadId, payload, signal, options), and optional methods cover queued runs, cancellation, history, and updateState. Pass an AgentTransport instance as transport: myTransport to either provideAgent() or agent(). FetchStreamTransport is the default.
+Implement the AgentTransport interface from @threadplane/langgraph. Its required method is stream(assistantId, threadId, payload, signal, options), and optional methods cover queued runs, cancellation, history, and updateState. Pass an AgentTransport instance as transport: myTransport to provideAgent(). FetchStreamTransport is the default.
-To pass a system prompt to the LangGraph agent per-thread, use the config option:
-agent({ config: { configurable: { system_prompt: 'You are a helpful assistant.' } } })
+To pass per-run LangGraph config, pass it when submitting:
+chat.submit({ message }, { config: { configurable: { system_prompt: 'You are a helpful assistant.' } } })
diff --git a/apps/website/content/prompts/getting-started.md b/apps/website/content/prompts/getting-started.md
index 7e66ab1e8..23ba06f3c 100644
--- a/apps/website/content/prompts/getting-started.md
+++ b/apps/website/content/prompts/getting-started.md
@@ -2,9 +2,9 @@ Add angular to my Angular 20+ application.
Install: npm install @threadplane/langgraph@latest
-1. In app.config.ts, add provideAgent({ apiUrl: 'http://localhost:2024' }) to the providers array. Import it from '@threadplane/langgraph'.
+1. In app.config.ts, add provideAgent({ apiUrl: 'http://localhost:2024', assistantId: 'chat' }) to the providers array. Import it from '@threadplane/langgraph'.
-2. Create a ChatComponent that calls agent({ assistantId: 'chat' }) in the constructor or as a field initializer. agent() MUST be called inside an Angular injection context — constructor or field initializer is correct; ngOnInit is not.
+2. Create a ChatComponent that calls injectAgent() in the constructor or as a field initializer. injectAgent() MUST be called inside an Angular injection context — constructor or field initializer is correct; ngOnInit is not.
3. The component template should loop over chat.messages() using @for and render each message's content. Add an input field and a button that calls chat.submit({ message: inputValue }).
diff --git a/apps/website/content/prompts/streaming.md b/apps/website/content/prompts/streaming.md
index 7e9c764eb..6c90e9560 100644
--- a/apps/website/content/prompts/streaming.md
+++ b/apps/website/content/prompts/streaming.md
@@ -1,12 +1,12 @@
Configure token-by-token streaming in my Angular component that uses angular.
-The component already has agent() set up. Now:
+The component already has injectAgent() set up from a configured provideAgent() provider. Now:
1. In the template, bind to chat.messages() with @for — each runtime-neutral Message has role and content fields. The template re-renders automatically as tokens arrive because messages() is a Signal.
2. Show a loading indicator while streaming: use chat.isLoading() in an @if block.
-3. To throttle rapid re-renders (if performance is a concern), pass throttle: 50 to agent() options — this throttles Signal updates to at most one per 50ms while preserving the final value.
+3. To throttle rapid re-renders (if performance is a concern), pass throttle: 50 to provideAgent() config — this throttles Signal updates to at most one per 50ms while preserving the final value.
4. To show the stream status more precisely, bind to chat.status() which returns 'idle' | 'running' | 'error'. Use chat.isLoading() for loading UI.
diff --git a/apps/website/content/prompts/testing.md b/apps/website/content/prompts/testing.md
index a39018cec..1e87508e9 100644
--- a/apps/website/content/prompts/testing.md
+++ b/apps/website/content/prompts/testing.md
@@ -4,7 +4,8 @@ Use MockAgentTransport from '@threadplane/langgraph'. It implements AgentTranspo
Test setup:
const transport = new MockAgentTransport();
-const chat = agent({ transport, assistantId: 'test', apiUrl: '' });
+providers: [provideAgent({ transport, assistantId: 'test', apiUrl: '' })]
+const chat = injectAgent();
To emit a streaming response:
transport.emit([
@@ -21,4 +22,4 @@ transport.emitError(new Error('Network failure'));
expect(chat.status()).toBe('error');
expect(chat.error()).toBeInstanceOf(Error);
-Never mock agent() itself — always use MockAgentTransport and test through the real function.
+Never mock injectAgent() itself — always use MockAgentTransport and test through the real provider.
diff --git a/apps/website/content/prompts/thread-persistence.md b/apps/website/content/prompts/thread-persistence.md
index 7be3acc45..c76cce173 100644
--- a/apps/website/content/prompts/thread-persistence.md
+++ b/apps/website/content/prompts/thread-persistence.md
@@ -4,10 +4,10 @@ Add thread persistence to my Angular component that uses angular, so conversatio
2. Create a signal: threadId = signal(storedId).
-3. Pass it to agent: agent({ ..., threadId: this.threadId, onThreadId: (id) => { this.threadId.set(id); localStorage.setItem('chat-thread-id', id); } }).
+3. Pass it to provideAgent: provideAgent({ assistantId: 'chat', threadId: this.threadId, onThreadId: (id) => { this.threadId.set(id); localStorage.setItem('chat-thread-id', id); } }).
4. The onThreadId callback fires once when the server creates a new thread. After that, the same thread ID is reused and the full conversation history is restored from the LangGraph server.
-5. To start a new conversation, call this.threadId.set(null) — this causes agent to create a fresh thread on the next submit.
+5. To start a new conversation, call this.threadId.set(null) — this causes the injected agent to create a fresh thread on the next submit.
No changes to the template are needed.
diff --git a/apps/website/public/AGENTS.md b/apps/website/public/AGENTS.md
index 1d5b40477..8ea908e96 100644
--- a/apps/website/public/AGENTS.md
+++ b/apps/website/public/AGENTS.md
@@ -1,4 +1,4 @@
-# Threadplane v0.0.46
+# Threadplane v0.0.54
Production-ready chat, durable threads, interrupts, subagents, planning, memory, and generative UI for Angular agent apps.
@@ -6,7 +6,7 @@ Production-ready chat, durable threads, interrupts, subagents, planning, memory,
npm install @threadplane/chat @threadplane/langgraph
## Key requirement
-`agent()` MUST be called within an Angular injection context (component constructor or field initializer). Calling it in ngOnInit or any async context throws "NG0203: inject() must be called from an injection context".
+`injectAgent()` MUST be called within an Angular injection context (component constructor or field initializer). Calling it in ngOnInit or any async context throws "NG0203: inject() must be called from an injection context".
## Basic usage
```typescript
@@ -14,12 +14,12 @@ npm install @threadplane/chat @threadplane/langgraph
import type { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/langgraph';
export const appConfig: ApplicationConfig = {
- providers: [provideAgent({ apiUrl: 'http://localhost:2024' })]
+ providers: [provideAgent({ apiUrl: 'http://localhost:2024', assistantId: 'chat_agent' })]
};
// chat.component.ts
import { Component } from '@angular/core';
-import { agent } from '@threadplane/langgraph';
+import { injectAgent } from '@threadplane/langgraph';
import { ChatComponent as ThreadplaneChatComponent } from '@threadplane/chat';
@Component({
@@ -29,15 +29,15 @@ import { ChatComponent as ThreadplaneChatComponent } from '@threadplane/chat';
`,
})
export class ChatComponent {
- chat = agent({ apiUrl: 'http://localhost:2024', assistantId: 'chat_agent' });
+ chat = injectAgent();
}
```
## Key patterns
-- Thread persistence: `threadId: signal(localStorage.getItem('t'))` + `onThreadId: (id) => localStorage.setItem('t', id)`
-- Global config: `provideAgent({ apiUrl })` in app.config.ts
-- Per-call override: pass `apiUrl` directly to `agent()`
-- Testing: use `MockAgentTransport` — never mock `agent()` itself
+- Thread persistence: configure `provideAgent({ assistantId, threadId: signal(localStorage.getItem('t')), onThreadId })`
+- Global config: `provideAgent({ apiUrl, assistantId })` in app.config.ts
+- Scoped config: re-provide `provideAgent({ apiUrl, assistantId })` in a component `providers` array for a subtree
+- Testing: use `MockAgentTransport` — never mock `injectAgent()` itself
## Version check
If this file is stale, fetch the latest: https://threadplane.ai/llms-full.txt
diff --git a/apps/website/public/CLAUDE.md b/apps/website/public/CLAUDE.md
index 1d5b40477..8ea908e96 100644
--- a/apps/website/public/CLAUDE.md
+++ b/apps/website/public/CLAUDE.md
@@ -1,4 +1,4 @@
-# Threadplane v0.0.46
+# Threadplane v0.0.54
Production-ready chat, durable threads, interrupts, subagents, planning, memory, and generative UI for Angular agent apps.
@@ -6,7 +6,7 @@ Production-ready chat, durable threads, interrupts, subagents, planning, memory,
npm install @threadplane/chat @threadplane/langgraph
## Key requirement
-`agent()` MUST be called within an Angular injection context (component constructor or field initializer). Calling it in ngOnInit or any async context throws "NG0203: inject() must be called from an injection context".
+`injectAgent()` MUST be called within an Angular injection context (component constructor or field initializer). Calling it in ngOnInit or any async context throws "NG0203: inject() must be called from an injection context".
## Basic usage
```typescript
@@ -14,12 +14,12 @@ npm install @threadplane/chat @threadplane/langgraph
import type { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/langgraph';
export const appConfig: ApplicationConfig = {
- providers: [provideAgent({ apiUrl: 'http://localhost:2024' })]
+ providers: [provideAgent({ apiUrl: 'http://localhost:2024', assistantId: 'chat_agent' })]
};
// chat.component.ts
import { Component } from '@angular/core';
-import { agent } from '@threadplane/langgraph';
+import { injectAgent } from '@threadplane/langgraph';
import { ChatComponent as ThreadplaneChatComponent } from '@threadplane/chat';
@Component({
@@ -29,15 +29,15 @@ import { ChatComponent as ThreadplaneChatComponent } from '@threadplane/chat';
`,
})
export class ChatComponent {
- chat = agent({ apiUrl: 'http://localhost:2024', assistantId: 'chat_agent' });
+ chat = injectAgent();
}
```
## Key patterns
-- Thread persistence: `threadId: signal(localStorage.getItem('t'))` + `onThreadId: (id) => localStorage.setItem('t', id)`
-- Global config: `provideAgent({ apiUrl })` in app.config.ts
-- Per-call override: pass `apiUrl` directly to `agent()`
-- Testing: use `MockAgentTransport` — never mock `agent()` itself
+- Thread persistence: configure `provideAgent({ assistantId, threadId: signal(localStorage.getItem('t')), onThreadId })`
+- Global config: `provideAgent({ apiUrl, assistantId })` in app.config.ts
+- Scoped config: re-provide `provideAgent({ apiUrl, assistantId })` in a component `providers` array for a subtree
+- Testing: use `MockAgentTransport` — never mock `injectAgent()` itself
## Version check
If this file is stale, fetch the latest: https://threadplane.ai/llms-full.txt
diff --git a/apps/website/scripts/generate-api-docs.ts b/apps/website/scripts/generate-api-docs.ts
index 6d720a842..9a9e0506a 100644
--- a/apps/website/scripts/generate-api-docs.ts
+++ b/apps/website/scripts/generate-api-docs.ts
@@ -173,6 +173,8 @@ interface LibraryEntryConfig {
docSlug: string;
/** TypeDoc entry points — usually libs//src/public-api.ts. */
entryPoints: string[];
+ /** Optional TypeScript config override when a package has multiple published entrypoint groups. */
+ tsconfig?: string;
}
const LIBRARIES: LibraryEntryConfig[] = [
@@ -181,6 +183,7 @@ const LIBRARIES: LibraryEntryConfig[] = [
{ docSlug: 'render', entryPoints: ['libs/render/src/public-api.ts'] },
{ docSlug: 'ag-ui', entryPoints: ['libs/ag-ui/src/public-api.ts'] },
{ docSlug: 'a2ui', entryPoints: ['libs/a2ui/src/index.ts'] },
+ { docSlug: 'middleware', entryPoints: ['libs/middleware/src/langgraph/index.ts'] },
{ docSlug: 'licensing', entryPoints: ['libs/licensing/src/index.ts'] },
{
docSlug: 'telemetry',
@@ -190,6 +193,7 @@ const LIBRARIES: LibraryEntryConfig[] = [
'libs/telemetry/src/node/index.ts',
'libs/telemetry/src/shared/public-api.ts',
],
+ tsconfig: 'libs/telemetry/tsconfig.spec.json',
},
];
@@ -203,10 +207,10 @@ async function generateForLibrary(cfg: LibraryEntryConfig): Promise {
return;
}
- const libDir = path.dirname(path.dirname(cfg.entryPoints[0]));
- const libTsconfig = fs.existsSync(path.join(libDir, 'tsconfig.lib.json'))
+ const libDir = findPackageRoot(cfg.entryPoints[0]);
+ const libTsconfig = cfg.tsconfig ?? (fs.existsSync(path.join(libDir, 'tsconfig.lib.json'))
? path.join(libDir, 'tsconfig.lib.json')
- : undefined;
+ : undefined);
const app = await Application.bootstrapWithPlugins({
entryPoints: cfg.entryPoints,
@@ -224,6 +228,15 @@ async function generateForLibrary(cfg: LibraryEntryConfig): Promise {
console.log(`✓ ${cfg.docSlug}/api/api-docs.json (${entries.length} entries)`);
}
+function findPackageRoot(entryPoint: string): string {
+ let dir = path.dirname(entryPoint);
+ while (dir !== path.dirname(dir)) {
+ if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
+ dir = path.dirname(dir);
+ }
+ return path.dirname(path.dirname(entryPoint));
+}
+
async function main() {
for (const cfg of LIBRARIES) {
await generateForLibrary(cfg);
diff --git a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx
index 16c6fe776..7f60f703b 100644
--- a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx
+++ b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx
@@ -27,15 +27,6 @@ function loadApiDocs(library: string): ApiDocEntry[] {
return [];
}
-const API_NAME_MAP: Record> = {
- langgraph: {
- 'inject-agent': 'injectAgent',
- 'provide-agent': 'provideAgent',
- 'fetch-stream-transport': 'FetchStreamTransport',
- 'mock-stream-transport': 'MockAgentTransport',
- },
-};
-
interface DocsRouteProps {
params: Promise<{ library: string; section: string; slug: string }>;
}
@@ -92,9 +83,8 @@ export default async function DocsPage({ params }: DocsRouteProps) {
{section === 'api' && (() => {
const entries = loadApiDocs(library);
- const nameMap = API_NAME_MAP[library] ?? {};
- const target = nameMap[slug];
- const apiEntry = target ? entries.find((e: ApiDocEntry) => e.name === target) : null;
+ const target = doc.title.replace(/\(\)$/, '');
+ const apiEntry = entries.find((e: ApiDocEntry) => e.name === target || e.name === doc.title);
return apiEntry ? (
diff --git a/apps/website/src/app/docs/page.tsx b/apps/website/src/app/docs/page.tsx
index d25376777..ee3369d43 100644
--- a/apps/website/src/app/docs/page.tsx
+++ b/apps/website/src/app/docs/page.tsx
@@ -76,10 +76,16 @@ interface SupportingLib {
title: string;
blurb: string;
href: string;
- glyph: 'key' | 'pulse';
+ glyph: 'key' | 'middleware' | 'pulse';
}
const SUPPORTING: SupportingLib[] = [
+ {
+ title: 'Middleware',
+ blurb: 'Backend client-tool routing',
+ href: '/docs/middleware/getting-started/introduction',
+ glyph: 'middleware',
+ },
{
title: 'Licensing',
blurb: 'Token verification',
@@ -119,7 +125,16 @@ function PulseGlyph() {
);
}
-const GLYPHS = { key: KeyGlyph, pulse: PulseGlyph } as const;
+function MiddlewareGlyph() {
+ return (
+
+ );
+}
+
+const GLYPHS = { key: KeyGlyph, middleware: MiddlewareGlyph, pulse: PulseGlyph } as const;
const stepLabelStyle = {
fontFamily: tokens.typography.eyebrow.family,
diff --git a/apps/website/src/app/llms-full.txt/route.ts b/apps/website/src/app/llms-full.txt/route.ts
index cbff378f0..21e3f11e4 100644
--- a/apps/website/src/app/llms-full.txt/route.ts
+++ b/apps/website/src/app/llms-full.txt/route.ts
@@ -6,6 +6,7 @@ import langgraphApiDocs from '../../../content/docs/langgraph/api/api-docs.json'
import agUiApiDocs from '../../../content/docs/ag-ui/api/api-docs.json';
import chatApiDocs from '../../../content/docs/chat/api/api-docs.json';
import licensingApiDocs from '../../../content/docs/licensing/api/api-docs.json';
+import middlewareApiDocs from '../../../content/docs/middleware/api/api-docs.json';
import renderApiDocs from '../../../content/docs/render/api/api-docs.json';
import telemetryApiDocs from '../../../content/docs/telemetry/api/api-docs.json';
@@ -15,6 +16,7 @@ const API_DOCS: Record = {
langgraph: langgraphApiDocs,
chat: chatApiDocs,
licensing: licensingApiDocs,
+ middleware: middlewareApiDocs,
render: renderApiDocs,
telemetry: telemetryApiDocs,
};
diff --git a/apps/website/src/components/docs/LibraryMark.tsx b/apps/website/src/components/docs/LibraryMark.tsx
index 480b055a5..e742d8eac 100644
--- a/apps/website/src/components/docs/LibraryMark.tsx
+++ b/apps/website/src/components/docs/LibraryMark.tsx
@@ -1,7 +1,7 @@
import { tokens } from '@threadplane/design-tokens';
import type { LibraryId } from '../../lib/docs-config';
-type GlyphKey = 'chat' | 'key' | 'pulse';
+type GlyphKey = 'chat' | 'key' | 'middleware' | 'pulse';
type MarkEntry =
| { kind: 'logo'; src: string }
@@ -13,6 +13,7 @@ const MARKS: Record = {
a2ui: { kind: 'logo', src: '/logos/providers/google.svg' },
render: { kind: 'logo', src: '/logos/surface/vercel.svg' },
chat: { kind: 'glyph', glyph: 'chat' },
+ middleware: { kind: 'glyph', glyph: 'middleware' },
licensing: { kind: 'glyph', glyph: 'key' },
telemetry: { kind: 'glyph', glyph: 'pulse' },
};
@@ -34,6 +35,15 @@ function KeyGlyph({ s }: { s: number }) {
);
}
+function MiddlewareGlyph({ s }: { s: number }) {
+ return (
+
+ );
+}
+
function PulseGlyph({ s }: { s: number }) {
return (