From 213dcdc2dde44249f55eb4269c50ba5842f9ee96 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 15 Jun 2026 20:46:57 -0700 Subject: [PATCH 1/8] feat(middleware): scaffold libs/middleware (@threadplane/middleware) Nx tsc/vitest package Co-Authored-By: Claude Sonnet 4.6 --- libs/middleware/README.md | 9 ++ libs/middleware/eslint.config.mjs | 2 + libs/middleware/package.json | 21 +++ libs/middleware/project.json | 21 +++ libs/middleware/src/langgraph/index.ts | 2 + libs/middleware/tsconfig.json | 9 ++ libs/middleware/tsconfig.lib.json | 6 + libs/middleware/tsconfig.spec.json | 5 + libs/middleware/vite.config.mts | 7 + package-lock.json | 188 ++++++++++++++++--------- package.json | 2 + 11 files changed, 205 insertions(+), 67 deletions(-) create mode 100644 libs/middleware/README.md create mode 100644 libs/middleware/eslint.config.mjs create mode 100644 libs/middleware/package.json create mode 100644 libs/middleware/project.json create mode 100644 libs/middleware/src/langgraph/index.ts create mode 100644 libs/middleware/tsconfig.json create mode 100644 libs/middleware/tsconfig.lib.json create mode 100644 libs/middleware/tsconfig.spec.json create mode 100644 libs/middleware/vite.config.mts diff --git a/libs/middleware/README.md b/libs/middleware/README.md new file mode 100644 index 000000000..5c3efd5da --- /dev/null +++ b/libs/middleware/README.md @@ -0,0 +1,9 @@ +# @threadplane/middleware + +Backend middleware for the Threadplane client-tools capability. + +## `@threadplane/middleware/langgraph` + +LangGraph.js helpers that bind frontend-declared client tools onto the model and route +client-tool-only turns to `END` so the browser executes them. See the +[client-tools guide](https://github.com/cacheplane/angular-agent-framework). diff --git a/libs/middleware/eslint.config.mjs b/libs/middleware/eslint.config.mjs new file mode 100644 index 000000000..492d6d0ea --- /dev/null +++ b/libs/middleware/eslint.config.mjs @@ -0,0 +1,2 @@ +import baseConfig from '../../eslint.config.mjs'; +export default [...baseConfig, { files: ['**/*.ts'] }]; diff --git a/libs/middleware/package.json b/libs/middleware/package.json new file mode 100644 index 000000000..b48e1819d --- /dev/null +++ b/libs/middleware/package.json @@ -0,0 +1,21 @@ +{ + "name": "@threadplane/middleware", + "version": "0.0.1", + "description": "Backend middleware for the Threadplane client-tools capability. The /langgraph entrypoint targets LangGraph.js.", + "keywords": ["langgraph", "agent", "client-tools", "middleware", "threadplane"], + "license": "MIT", + "type": "module", + "sideEffects": false, + "publishConfig": { "access": "public" }, + "repository": { "type": "git", "url": "https://github.com/cacheplane/angular-agent-framework.git", "directory": "libs/middleware" }, + "homepage": "https://github.com/cacheplane/angular-agent-framework#readme", + "bugs": { "url": "https://github.com/cacheplane/angular-agent-framework/issues" }, + "exports": { + "./langgraph": { "types": "./src/langgraph/index.d.ts", "default": "./src/langgraph/index.js" }, + "./README.md": "./README.md" + }, + "peerDependencies": { + "@langchain/core": "^1.0.0", + "@langchain/langgraph": "^1.0.0" + } +} diff --git a/libs/middleware/project.json b/libs/middleware/project.json new file mode 100644 index 000000000..bc0804cc0 --- /dev/null +++ b/libs/middleware/project.json @@ -0,0 +1,21 @@ +{ + "name": "middleware", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/middleware/src", + "projectType": "library", + "tags": ["type:lib", "scope:library", "scope:shared"], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/libs/middleware"], + "options": { + "outputPath": "dist/libs/middleware", + "main": "libs/middleware/src/langgraph/index.ts", + "tsConfig": "libs/middleware/tsconfig.lib.json", + "assets": ["libs/middleware/README.md", "libs/middleware/package.json"] + } + }, + "test": { "executor": "@nx/vitest:test", "options": { "configFile": "libs/middleware/vite.config.mts" } }, + "lint": { "executor": "@nx/eslint:lint" } + } +} diff --git a/libs/middleware/src/langgraph/index.ts b/libs/middleware/src/langgraph/index.ts new file mode 100644 index 000000000..391b8ccfb --- /dev/null +++ b/libs/middleware/src/langgraph/index.ts @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: MIT +export {}; diff --git a/libs/middleware/tsconfig.json b/libs/middleware/tsconfig.json new file mode 100644 index 000000000..5de7c98ac --- /dev/null +++ b/libs/middleware/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "esnext", "moduleResolution": "bundler", "target": "es2022", + "strict": true, "esModuleInterop": true, "skipLibCheck": true, "resolveJsonModule": true, + "lib": ["es2022"] + }, + "include": [] +} diff --git a/libs/middleware/tsconfig.lib.json b/libs/middleware/tsconfig.lib.json new file mode 100644 index 000000000..f1d80d650 --- /dev/null +++ b/libs/middleware/tsconfig.lib.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "outDir": "../../dist/out-tsc", "rootDir": "src", "declaration": true, "emitDeclarationOnly": false }, + "include": ["src/langgraph/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/libs/middleware/tsconfig.spec.json b/libs/middleware/tsconfig.spec.json new file mode 100644 index 000000000..85bf0c962 --- /dev/null +++ b/libs/middleware/tsconfig.spec.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "outDir": "../../dist/out-tsc", "declaration": false, "types": ["vitest/globals", "node"] }, + "include": ["src/**/*.spec.ts", "src/**/*.ts"] +} diff --git a/libs/middleware/vite.config.mts b/libs/middleware/vite.config.mts new file mode 100644 index 000000000..7bfaaa4f2 --- /dev/null +++ b/libs/middleware/vite.config.mts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + plugins: [nxViteTsPaths()], + test: { environment: 'node', globals: true, include: ['src/**/*.spec.ts'] }, +}); diff --git a/package-lock.json b/package-lock.json index 3c943c4c0..ee7877664 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "stripe": "^22.0.2" }, "devDependencies": { + "@ag-ui/langgraph": "^0.0.41", "@analogjs/vite-plugin-angular": "^2.4.4", "@angular-devkit/build-angular": "^21.2.6", "@angular-devkit/core": "~21.1.0", @@ -61,6 +62,7 @@ "@copilotkit/aimock": "^1.23.0", "@eslint/js": "^9.8.0", "@json-render/core": "^0.16.0", + "@langchain/langgraph": "^1.4.2", "@nx/angular": "^22.5.4", "@nx/eslint": "22.5.4", "@nx/eslint-plugin": "22.5.4", @@ -180,15 +182,16 @@ }, "libs/a2ui": { "name": "@threadplane/a2ui", - "version": "0.0.47", + "version": "0.0.50", "license": "MIT" }, "libs/ag-ui": { "name": "@threadplane/ag-ui", - "version": "0.0.47", + "version": "0.0.50", "license": "MIT", "peerDependencies": { "@ag-ui/client": "*", + "@ag-ui/core": "*", "@angular/core": "^20.0.0 || ^21.0.0", "@threadplane/chat": "*", "rxjs": "~7.8.0" @@ -196,7 +199,7 @@ }, "libs/chat": { "name": "@threadplane/chat", - "version": "0.0.47", + "version": "0.0.50", "license": "PolyForm-Noncommercial-1.0.0 OR LicenseRef-Threadplane-Commercial", "dependencies": { "@cacheplane/partial-json": ">=0.1.1 <0.3.0", @@ -205,7 +208,6 @@ "peerDependencies": { "@angular/common": "^20.0.0 || ^21.0.0", "@angular/core": "^20.0.0 || ^21.0.0", - "@angular/forms": "^20.0.0 || ^21.0.0", "@angular/platform-browser": "^20.0.0 || ^21.0.0", "@json-render/core": "^0.16.0", "@langchain/core": "^1.1.33", @@ -213,7 +215,8 @@ "@threadplane/licensing": "*", "@threadplane/render": "*", "marked": "^15.0.0 || ^16.0.0", - "rxjs": "~7.8.0" + "rxjs": "~7.8.0", + "zod": "^3.25.0" } }, "libs/cockpit-docs": { @@ -324,7 +327,7 @@ }, "libs/langgraph": { "name": "@threadplane/langgraph", - "version": "0.0.47", + "version": "0.0.50", "license": "MIT", "peerDependencies": { "@angular/core": "^20.0.0 || ^21.0.0", @@ -336,15 +339,24 @@ }, "libs/licensing": { "name": "@threadplane/licensing", - "version": "0.0.47", + "version": "0.0.50", "license": "MIT", "peerDependencies": { "@noble/ed25519": "^2.2.3" } }, + "libs/middleware": { + "name": "@threadplane/middleware", + "version": "0.0.1", + "license": "MIT", + "peerDependencies": { + "@langchain/core": "^1.0.0", + "@langchain/langgraph": "^1.0.0" + } + }, "libs/render": { "name": "@threadplane/render", - "version": "0.0.47", + "version": "0.0.50", "license": "MIT", "peerDependencies": { "@angular/common": "^20.0.0 || ^21.0.0", @@ -354,7 +366,7 @@ }, "libs/telemetry": { "name": "@threadplane/telemetry", - "version": "0.0.47", + "version": "0.0.50", "license": "MIT", "bin": { "threadplane-telemetry-postinstall": "node/postinstall.js" @@ -406,6 +418,12 @@ "version": "0.0.0", "license": "MIT" }, + "node_modules/@ag-ui/a2ui-toolkit": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@ag-ui/a2ui-toolkit/-/a2ui-toolkit-0.0.3.tgz", + "integrity": "sha512-bKjtuYQufGZ+vc2oTz1v5S6ab2gH/whQIIgbGfP+LMisdAkDV7bqeg4e+lZO3xNmdmkCa6nvkovtudMkqxmxEA==", + "dev": true + }, "node_modules/@ag-ui/client": { "version": "0.0.52", "resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.52.tgz", @@ -453,6 +471,24 @@ "@ag-ui/proto": "0.0.52" } }, + "node_modules/@ag-ui/langgraph": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@ag-ui/langgraph/-/langgraph-0.0.41.tgz", + "integrity": "sha512-xo7ja/kuctmdPiH83QOUIpDs/AY3GzxW1fM37x9otK9fqwnKgi2JIcjfcdvAdGYdsCkXBn2WWQ2PVH+rdsLOzg==", + "dev": true, + "dependencies": { + "@ag-ui/a2ui-toolkit": "0.0.3", + "@langchain/core": "^1.1.40", + "@langchain/langgraph-sdk": "^1.8.8", + "langchain": ">=1.2.0", + "partial-json": "^0.1.7", + "rxjs": "7.8.1" + }, + "peerDependencies": { + "@ag-ui/client": ">=0.0.42", + "@ag-ui/core": ">=0.0.42" + } + }, "node_modules/@ag-ui/proto": { "version": "0.0.52", "resolved": "https://registry.npmjs.org/@ag-ui/proto/-/proto-0.0.52.tgz", @@ -10409,78 +10445,81 @@ } }, "node_modules/@langchain/core": { - "version": "1.1.33", - "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.33.tgz", - "integrity": "sha512-At1ooBmPlHMkhTkG6NqeOVjNscuJwneBB8F88rFRvBvIfhTACVLzEwMiZFWNTM8DzUXUOcxxqS7xKRyr6JBbOQ==", + "version": "1.1.49", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.49.tgz", + "integrity": "sha512-7wkN3Qv/qZqsY0p3h48CNu6E6y5GMYatYxj+JrX4uVNBiqIVQm1Z528QrmayJWVW9SQTQicqRNoyTCzl+K9F8Q==", "license": "MIT", "dependencies": { "@cfworker/json-schema": "^4.0.2", "@standard-schema/spec": "^1.1.0", - "ansi-styles": "^5.0.0", - "camelcase": "6", - "decamelize": "1.2.0", "js-tiktoken": "^1.0.12", "langsmith": ">=0.5.0 <1.0.0", "mustache": "^4.2.0", "p-queue": "^6.6.2", - "uuid": "^11.1.0", "zod": "^3.25.76 || ^4" }, "engines": { "node": ">=20" } }, - "node_modules/@langchain/core/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/@langchain/langgraph": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.2.tgz", + "integrity": "sha512-ivhYwbEKW4i/x2JfHcrTrToEE9EXZnwr4dPj7GC5974xEYeLgHYzii3GAYo1kgU5A0ZAd7rIxTpMOfcbycxliQ==", + "dev": true, "license": "MIT", + "dependencies": { + "@langchain/langgraph-checkpoint": "^1.1.1", + "@langchain/langgraph-sdk": "~1.9.22", + "@langchain/protocol": "^0.0.16", + "@standard-schema/spec": "1.1.0" + }, "engines": { - "node": ">=10" + "node": ">=18" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "peerDependencies": { + "@langchain/core": "^1.1.48", + "zod": "^3.25.32 || ^4.2.0", + "zod-to-json-schema": "^3.x" + }, + "peerDependenciesMeta": { + "zod-to-json-schema": { + "optional": true + } } }, - "node_modules/@langchain/core/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], + "node_modules/@langchain/langgraph-checkpoint": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.1.1.tgz", + "integrity": "sha512-gHqhO6e2dyZ7TTfyaFy25yjcRsavURc9XMGT4q+LUBTc0hT4JxKe3qvrMX2OFTzW8W/0kjV59haHmSRFZIGkvg==", + "dev": true, "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": "^1.1.48" } }, "node_modules/@langchain/langgraph-sdk": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.7.4.tgz", - "integrity": "sha512-SuQyFvL9Q/eBJdSAHLaM1mmfKoh5JAmRF4PdIokX9pyVYBvJqUpvsOcUYtkC3zniHOh/65y1eqvojt/WgPvN8Q==", + "version": "1.9.22", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.22.tgz", + "integrity": "sha512-DBKs9R2SGivlGqK/ZRTOUu39Q7Z+yRrG4PoTYLIWn7pqrLNhyZ4yZI/tEEEi/J0inpCuKfg/eydSwnRmPV/q3w==", "license": "MIT", "dependencies": { + "@langchain/protocol": "^0.0.16", "@types/json-schema": "^7.0.15", "p-queue": "^9.0.1", - "p-retry": "^7.1.1", - "uuid": "^13.0.0" + "p-retry": "^7.1.1" }, "peerDependencies": { - "@angular/core": "^18.0.0 || ^19.0.0 || ^20.0.0", - "@langchain/core": "^1.1.16", + "@langchain/core": "^1.1.48", "react": "^18 || ^19", "react-dom": "^18 || ^19", "svelte": "^4.0.0 || ^5.0.0", "vue": "^3.0.0" }, "peerDependenciesMeta": { - "@angular/core": { - "optional": true - }, - "@langchain/core": { - "optional": true - }, "react": { "optional": true }, @@ -10544,18 +10583,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@langchain/langgraph-sdk/node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } + "node_modules/@langchain/protocol": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@langchain/protocol/-/protocol-0.0.16.tgz", + "integrity": "sha512-ws+J7MaHyhO5dG7f0vdyHQiUn9hoCnki0f3crJPa4MCTGzcRC39jYSCghyrGtBPYQnZbUQiGyRVpW3z3M8IpJg==", + "license": "MIT" }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", @@ -20456,6 +20488,10 @@ "resolved": "marketing/metrics", "link": true }, + "node_modules/@threadplane/middleware": { + "resolved": "libs/middleware", + "link": true + }, "node_modules/@threadplane/minting-service": { "resolved": "apps/minting-service", "link": true @@ -25080,6 +25116,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -26580,15 +26617,6 @@ } } }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -32624,6 +32652,25 @@ "node": ">= 0.6" } }, + "node_modules/langchain": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.4.5.tgz", + "integrity": "sha512-P625jmIg91XwZoll6H3tyOLux1wQPjSptdGdiDdSrZVyUmeWKwzJu0+mmJjluNRCQVgzqCZzy1RWkz9p+vb+3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@langchain/langgraph": "^1.3.4", + "@langchain/langgraph-checkpoint": "^1.0.4", + "langsmith": ">=0.5.0 <1.0.0", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@langchain/core": "^1.1.49" + } + }, "node_modules/langsmith": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.5.10.tgz", @@ -37255,6 +37302,13 @@ "node": ">= 0.8" } }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "dev": true, + "license": "MIT" + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", diff --git a/package.json b/package.json index 335cc2183..3f50fa557 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "rxjs": "~7.8.0" }, "devDependencies": { + "@ag-ui/langgraph": "^0.0.41", "@analogjs/vite-plugin-angular": "^2.4.4", "@angular-devkit/build-angular": "^21.2.6", "@angular-devkit/core": "~21.1.0", @@ -37,6 +38,7 @@ "@copilotkit/aimock": "^1.23.0", "@eslint/js": "^9.8.0", "@json-render/core": "^0.16.0", + "@langchain/langgraph": "^1.4.2", "@nx/angular": "^22.5.4", "@nx/eslint": "22.5.4", "@nx/eslint-plugin": "22.5.4", From 5cd76f267ecf8fb18209d8b932321e644c7a1488 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 15 Jun 2026 20:53:39 -0700 Subject: [PATCH 2/8] feat(middleware): clientToolSpecs + clientToolNames (mirror python catalog) --- libs/middleware/src/langgraph.spec.ts | 29 +++++++++++++++++++++ libs/middleware/src/langgraph/middleware.ts | 21 +++++++++++++++ libs/middleware/src/langgraph/types.ts | 26 ++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 libs/middleware/src/langgraph.spec.ts create mode 100644 libs/middleware/src/langgraph/middleware.ts create mode 100644 libs/middleware/src/langgraph/types.ts diff --git a/libs/middleware/src/langgraph.spec.ts b/libs/middleware/src/langgraph.spec.ts new file mode 100644 index 000000000..87e9afbf2 --- /dev/null +++ b/libs/middleware/src/langgraph.spec.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { clientToolSpecs, clientToolNames } from './langgraph/middleware'; + +const WEATHER = { name: 'get_weather', description: 'Weather', parameters: { type: 'object' } }; + +describe('clientToolSpecs', () => { + it('wraps each catalog entry as an OpenAI function tool', () => { + expect(clientToolSpecs({ messages: [], tools: [WEATHER] })).toEqual([ + { type: 'function', function: { name: 'get_weather', description: 'Weather', parameters: { type: 'object' } } }, + ]); + }); + it('falls back to client_tools when tools is absent', () => { + expect(clientToolSpecs({ messages: [], client_tools: [WEATHER] })).toHaveLength(1); + }); + it('defaults missing description/parameters and drops nameless entries', () => { + const specs = clientToolSpecs({ messages: [], tools: [{ name: 'x' } as never, { description: 'no name' } as never] }); + expect(specs).toEqual([{ type: 'function', function: { name: 'x', description: '', parameters: {} } }]); + }); + it('returns [] for empty state', () => { + expect(clientToolSpecs({ messages: [] })).toEqual([]); + }); +}); + +describe('clientToolNames', () => { + it('returns the set of catalog names', () => { + expect(clientToolNames({ messages: [], tools: [WEATHER] })).toEqual(new Set(['get_weather'])); + }); +}); diff --git a/libs/middleware/src/langgraph/middleware.ts b/libs/middleware/src/langgraph/middleware.ts new file mode 100644 index 000000000..4f145b70d --- /dev/null +++ b/libs/middleware/src/langgraph/middleware.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +import type { ClientToolSpec, ClientToolsState, OpenAIFunctionTool } from './types'; + +/** Read the catalog from state.tools, falling back to state.client_tools; drop nameless. */ +function catalog(state: ClientToolsState): ClientToolSpec[] { + const raw = state.tools && state.tools.length > 0 ? state.tools : state.client_tools; + return (raw ?? []).filter((t): t is ClientToolSpec => !!t && typeof t === 'object' && !!t.name); +} + +/** The client catalog as OpenAI function-tool dicts for `model.bindTools`. */ +export function clientToolSpecs(state: ClientToolsState): OpenAIFunctionTool[] { + return catalog(state).map((t) => ({ + type: 'function', + function: { name: t.name, description: t.description ?? '', parameters: t.parameters ?? {} }, + })); +} + +/** The set of tool names declared by the client in this run. */ +export function clientToolNames(state: ClientToolsState): Set { + return new Set(catalog(state).map((t) => t.name)); +} diff --git a/libs/middleware/src/langgraph/types.ts b/libs/middleware/src/langgraph/types.ts new file mode 100644 index 000000000..73e1c6781 --- /dev/null +++ b/libs/middleware/src/langgraph/types.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +import type { BaseMessage } from '@langchain/core/messages'; + +/** A frontend-declared client tool: name + description + JSON-Schema parameters. */ +export interface ClientToolSpec { + name: string; + description?: string; + parameters?: Record; +} + +/** The explicit OpenAI function-tool shape accepted by ChatModel.bindTools across versions. */ +export interface OpenAIFunctionTool { + type: 'function'; + function: { name: string; description: string; parameters: Record }; +} + +/** The slice of graph state this middleware reads. */ +export interface ClientToolsState { + messages: BaseMessage[]; + /** Primary channel — AG-UI/LangGraph merges RunAgentInput.tools here. */ + tools?: ClientToolSpec[]; + /** Fallback channel — the raw run input key. */ + client_tools?: ClientToolSpec[]; +} + +export type { BaseMessage }; From 47f14962417b277febd011ee54a1471ac26e2942 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 15 Jun 2026 20:54:26 -0700 Subject: [PATCH 3/8] feat(middleware): lastMessage + client/server tool-call predicates --- libs/middleware/src/langgraph.spec.ts | 39 ++++++++++++++++++ libs/middleware/src/langgraph/middleware.ts | 45 +++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/libs/middleware/src/langgraph.spec.ts b/libs/middleware/src/langgraph.spec.ts index 87e9afbf2..5e1dbcca0 100644 --- a/libs/middleware/src/langgraph.spec.ts +++ b/libs/middleware/src/langgraph.spec.ts @@ -27,3 +27,42 @@ describe('clientToolNames', () => { expect(clientToolNames({ messages: [], tools: [WEATHER] })).toEqual(new Set(['get_weather'])); }); }); + +import { lastMessage, hasClientToolCall, hasServerToolCall } from './langgraph/middleware'; +import { AIMessage, HumanMessage } from '@langchain/core/messages'; + +const stateWith = (toolCalls: { name: string }[]) => ({ + messages: [new HumanMessage('hi'), new AIMessage({ content: '', tool_calls: toolCalls.map((c) => ({ name: c.name, args: {}, id: c.name })) })], + tools: [{ name: 'get_weather', description: '', parameters: {} }], +}); + +describe('lastMessage', () => { + it('returns the last message or undefined', () => { + expect(lastMessage({ messages: [] })).toBeUndefined(); + expect(lastMessage({ messages: [new HumanMessage('a'), new HumanMessage('b')] })?.content).toBe('b'); + }); +}); + +describe('hasClientToolCall', () => { + it('true when the last AI message calls a client tool', () => { + expect(hasClientToolCall(stateWith([{ name: 'get_weather' }]))).toBe(true); + }); + it('false when the last AI message calls only non-client tools', () => { + expect(hasClientToolCall(stateWith([{ name: 'search' }]))).toBe(false); + }); + it('false when there are no tool calls', () => { + expect(hasClientToolCall(stateWith([]))).toBe(false); + }); +}); + +describe('hasServerToolCall', () => { + it('true when a call name is in serverToolNames', () => { + expect(hasServerToolCall(stateWith([{ name: 'search' }]), ['search'])).toBe(true); + }); + it('true when a call name is unknown (not a client tool)', () => { + expect(hasServerToolCall(stateWith([{ name: 'mystery' }]), [])).toBe(true); + }); + it('false when the only call is a known client tool', () => { + expect(hasServerToolCall(stateWith([{ name: 'get_weather' }]), [])).toBe(false); + }); +}); diff --git a/libs/middleware/src/langgraph/middleware.ts b/libs/middleware/src/langgraph/middleware.ts index 4f145b70d..ea78c43ba 100644 --- a/libs/middleware/src/langgraph/middleware.ts +++ b/libs/middleware/src/langgraph/middleware.ts @@ -19,3 +19,48 @@ export function clientToolSpecs(state: ClientToolsState): OpenAIFunctionTool[] { export function clientToolNames(state: ClientToolsState): Set { return new Set(catalog(state).map((t) => t.name)); } + +import type { BaseMessage } from './types'; + +interface ToolCallLike { + name?: string; + function?: { name?: string }; +} + +function toolCalls(message: unknown): ToolCallLike[] { + const tc = (message as { tool_calls?: unknown } | null)?.tool_calls; + return Array.isArray(tc) ? (tc as ToolCallLike[]) : []; +} + +function callName(call: ToolCallLike): string | undefined { + return call.name ?? call.function?.name; +} + +/** The last message from state.messages, or undefined. */ +export function lastMessage(state: ClientToolsState): BaseMessage | undefined { + const msgs = state.messages ?? []; + return msgs.length ? msgs[msgs.length - 1] : undefined; +} + +/** True if the last message calls at least one client tool. */ +export function hasClientToolCall(state: ClientToolsState): boolean { + const names = clientToolNames(state); + return toolCalls(lastMessage(state)).some((c) => { + const n = callName(c); + return n !== undefined && names.has(n); + }); +} + +/** + * True if the last message calls at least one server (non-client) tool. + * A call is server-side when its name is in serverToolNames OR is not a known + * client tool (unknown tools are assumed server-side). + */ +export function hasServerToolCall(state: ClientToolsState, serverToolNames: Iterable): boolean { + const server = new Set(serverToolNames); + const client = clientToolNames(state); + return toolCalls(lastMessage(state)).some((c) => { + const n = callName(c); + return n !== undefined && (server.has(n) || !client.has(n)); + }); +} From 46c038ebb6dfab43ecab5494ea64fa8cc43cea08 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 15 Jun 2026 20:55:25 -0700 Subject: [PATCH 4/8] feat(middleware): bindClientTools + routeAfterAgent + public index (extras pending) --- libs/middleware/src/langgraph.spec.ts | 44 +++++++++++++++++++++ libs/middleware/src/langgraph/index.ts | 15 ++++++- libs/middleware/src/langgraph/middleware.ts | 32 +++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/libs/middleware/src/langgraph.spec.ts b/libs/middleware/src/langgraph.spec.ts index 5e1dbcca0..bed072102 100644 --- a/libs/middleware/src/langgraph.spec.ts +++ b/libs/middleware/src/langgraph.spec.ts @@ -66,3 +66,47 @@ describe('hasServerToolCall', () => { expect(hasServerToolCall(stateWith([{ name: 'get_weather' }]), [])).toBe(false); }); }); + +import { bindClientTools, routeAfterAgent } from './langgraph/middleware'; + +describe('bindClientTools', () => { + it('binds server tools then client stubs (server first), calling bindTools once', () => { + const calls: unknown[][] = []; + const fake = { bindTools: (tools: unknown[]) => { calls.push(tools); return 'BOUND'; } }; + const SERVER = { name: 'search' }; + const result = bindClientTools(fake as never, [SERVER as never], { messages: [], tools: [{ name: 'get_weather', description: '', parameters: {} }] }); + expect(result).toBe('BOUND'); + expect(calls).toHaveLength(1); + expect(calls[0][0]).toBe(SERVER); + expect((calls[0][1] as { function: { name: string } }).function.name).toBe('get_weather'); + }); + it('binds only server tools when there is no client catalog', () => { + let bound: unknown[] = []; + const fake = { bindTools: (tools: unknown[]) => { bound = tools; return fake; } }; + bindClientTools(fake as never, [{ name: 'search' } as never], { messages: [] }); + expect(bound).toHaveLength(1); + }); +}); + +describe('routeAfterAgent', () => { + const st = (names: string[]) => ({ + messages: [new AIMessage({ content: '', tool_calls: names.map((n) => ({ name: n, args: {}, id: n })) })], + tools: [{ name: 'get_weather', description: '', parameters: {} }], + }); + it('routes a server tool call to the tools node', () => { + expect(routeAfterAgent(st(['search']), ['search'])).toBe('tools'); + }); + it('routes a client-only tool call to END', () => { + expect(routeAfterAgent(st(['get_weather']), [])).toBe('__end__'); + }); + it('routes no tool calls to END', () => { + expect(routeAfterAgent(st([]), [])).toBe('__end__'); + }); + it('routes a mixed call to the server (precedence)', () => { + expect(routeAfterAgent(st(['get_weather', 'search']), ['search'])).toBe('tools'); + }); + it('honors custom node names', () => { + expect(routeAfterAgent(st(['search']), ['search'], { toolsNode: 'act' })).toBe('act'); + expect(routeAfterAgent(st([]), [], { end: 'DONE' })).toBe('DONE'); + }); +}); diff --git a/libs/middleware/src/langgraph/index.ts b/libs/middleware/src/langgraph/index.ts index 391b8ccfb..049a9c514 100644 --- a/libs/middleware/src/langgraph/index.ts +++ b/libs/middleware/src/langgraph/index.ts @@ -1,2 +1,15 @@ // SPDX-License-Identifier: MIT -export {}; +export type { ClientToolSpec, ClientToolsState, OpenAIFunctionTool, BaseMessage } from './types'; +export { + clientToolSpecs, + clientToolNames, + lastMessage, + hasClientToolCall, + hasServerToolCall, + bindClientTools, + routeAfterAgent, + type BindableModel, +} from './middleware'; +// extras (added in later tasks): +// export { clientToolsChannel } from './channel'; +// export { clientToolsRouter } from './router'; diff --git a/libs/middleware/src/langgraph/middleware.ts b/libs/middleware/src/langgraph/middleware.ts index ea78c43ba..eaa68f04a 100644 --- a/libs/middleware/src/langgraph/middleware.ts +++ b/libs/middleware/src/langgraph/middleware.ts @@ -64,3 +64,35 @@ export function hasServerToolCall(state: ClientToolsState, serverToolNames: Iter return n !== undefined && (server.has(n) || !client.has(n)); }); } + +/** A chat model that can bind tools (the LangChain `Runnable.bindTools` surface). */ +export interface BindableModel { + bindTools(tools: unknown[], kwargs?: unknown): unknown; +} + +/** + * Bind server tools + the client catalog stubs onto `llm`. Call this INSIDE the + * agent node (per-run) — the client catalog arrives in state and may differ per run. + */ +export function bindClientTools( + llm: M, + serverTools: unknown[], + state: ClientToolsState, +): ReturnType { + return llm.bindTools([...serverTools, ...clientToolSpecs(state)]) as ReturnType; +} + +/** + * Routing helper for a LangGraph conditional edge. Returns `toolsNode` when the last + * message has a server tool call (dispatch to the server ToolNode); otherwise `end` + * (client-only calls — the browser executes them — and no-tool-call turns both end). + */ +export function routeAfterAgent( + state: ClientToolsState, + serverToolNames: Iterable, + opts?: { toolsNode?: string; end?: string }, +): string { + const toolsNode = opts?.toolsNode ?? 'tools'; + const end = opts?.end ?? '__end__'; + return hasServerToolCall(state, serverToolNames) ? toolsNode : end; +} From 5eeaea3c2970c5cf2b1585e9a1662a5a002c67a1 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 15 Jun 2026 20:57:06 -0700 Subject: [PATCH 5/8] feat(middleware): clientToolsChannel Annotation fragment --- libs/middleware/src/langgraph.spec.ts | 12 ++++++++++++ libs/middleware/src/langgraph/channel.ts | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 libs/middleware/src/langgraph/channel.ts diff --git a/libs/middleware/src/langgraph.spec.ts b/libs/middleware/src/langgraph.spec.ts index bed072102..d9c4df45f 100644 --- a/libs/middleware/src/langgraph.spec.ts +++ b/libs/middleware/src/langgraph.spec.ts @@ -68,6 +68,18 @@ describe('hasServerToolCall', () => { }); import { bindClientTools, routeAfterAgent } from './langgraph/middleware'; +import { clientToolsChannel } from './langgraph/channel'; +import { Annotation, MessagesAnnotation } from '@langchain/langgraph'; + +describe('clientToolsChannel', () => { + it('produces tools + client_tools channels usable in Annotation.Root', () => { + const frag = clientToolsChannel(); + expect(Object.keys(frag).sort()).toEqual(['client_tools', 'tools']); + const State = Annotation.Root({ ...MessagesAnnotation.spec, ...frag }); + expect(State.spec).toHaveProperty('tools'); + expect(State.spec).toHaveProperty('client_tools'); + }); +}); describe('bindClientTools', () => { it('binds server tools then client stubs (server first), calling bindTools once', () => { diff --git a/libs/middleware/src/langgraph/channel.ts b/libs/middleware/src/langgraph/channel.ts new file mode 100644 index 000000000..37168fc5e --- /dev/null +++ b/libs/middleware/src/langgraph/channel.ts @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +import { Annotation } from '@langchain/langgraph'; +import type { ClientToolSpec } from './types'; + +/** + * State channels for the client-tools catalog. Spread into Annotation.Root so a graph + * declares the `tools` (primary) and `client_tools` (fallback) slices in one line: + * + * const State = Annotation.Root({ ...MessagesAnnotation.spec, ...clientToolsChannel() }); + * + * Both are last-value-wins channels (the catalog is replaced per run, not accumulated). + */ +export function clientToolsChannel() { + return { + tools: Annotation(), + client_tools: Annotation(), + }; +} From bb4808e5fc5a6cccbf424fcf30099a0dcbc16133 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 15 Jun 2026 20:58:04 -0700 Subject: [PATCH 6/8] feat(middleware): clientToolsRouter factory + enable extras export --- libs/middleware/src/langgraph.spec.ts | 18 ++++++++++++++++++ libs/middleware/src/langgraph/index.ts | 5 ++--- libs/middleware/src/langgraph/router.ts | 17 +++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 libs/middleware/src/langgraph/router.ts diff --git a/libs/middleware/src/langgraph.spec.ts b/libs/middleware/src/langgraph.spec.ts index d9c4df45f..415ce3d8f 100644 --- a/libs/middleware/src/langgraph.spec.ts +++ b/libs/middleware/src/langgraph.spec.ts @@ -122,3 +122,21 @@ describe('routeAfterAgent', () => { expect(routeAfterAgent(st([]), [], { end: 'DONE' })).toBe('DONE'); }); }); + +import { clientToolsRouter } from './langgraph/router'; + +describe('clientToolsRouter', () => { + const st = (names: string[]) => ({ + messages: [new AIMessage({ content: '', tool_calls: names.map((n) => ({ name: n, args: {}, id: n })) })], + tools: [{ name: 'get_weather', description: '', parameters: {} }], + }); + it('returns a callback that routes via routeAfterAgent with bound serverToolNames', () => { + const route = clientToolsRouter(['search']); + expect(route(st(['search']))).toBe('tools'); + expect(route(st(['get_weather']))).toBe('__end__'); + }); + it('honors custom node names', () => { + const route = clientToolsRouter([], { end: 'DONE' }); + expect(route(st([]))).toBe('DONE'); + }); +}); diff --git a/libs/middleware/src/langgraph/index.ts b/libs/middleware/src/langgraph/index.ts index 049a9c514..f4a5e38ea 100644 --- a/libs/middleware/src/langgraph/index.ts +++ b/libs/middleware/src/langgraph/index.ts @@ -10,6 +10,5 @@ export { routeAfterAgent, type BindableModel, } from './middleware'; -// extras (added in later tasks): -// export { clientToolsChannel } from './channel'; -// export { clientToolsRouter } from './router'; +export { clientToolsChannel } from './channel'; +export { clientToolsRouter } from './router'; diff --git a/libs/middleware/src/langgraph/router.ts b/libs/middleware/src/langgraph/router.ts new file mode 100644 index 000000000..6d6dab7a6 --- /dev/null +++ b/libs/middleware/src/langgraph/router.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +import { routeAfterAgent } from './middleware'; +import type { ClientToolsState } from './types'; + +/** + * A prebuilt conditional-edge callback. serverToolNames is bound once at construction; + * the returned function takes only state. + * + * graph.addConditionalEdges('agent', clientToolsRouter([]), ['tools', END]); + */ +export function clientToolsRouter( + serverToolNames: Iterable, + opts?: { toolsNode?: string; end?: string }, +): (state: ClientToolsState) => string { + const names = [...serverToolNames]; + return (state: ClientToolsState) => routeAfterAgent(state, names, opts); +} From 58424c0ab18067549bf2f739d4cd9d4e67ddebbe Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 15 Jun 2026 21:00:09 -0700 Subject: [PATCH 7/8] test(middleware): in-process StateGraph client-tools loop integration Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/middleware/src/integration.spec.ts | 53 +++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 libs/middleware/src/integration.spec.ts diff --git a/libs/middleware/src/integration.spec.ts b/libs/middleware/src/integration.spec.ts new file mode 100644 index 000000000..ef3a62720 --- /dev/null +++ b/libs/middleware/src/integration.spec.ts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { Annotation, MessagesAnnotation, StateGraph, END } from '@langchain/langgraph'; +import { AIMessage, ToolMessage, HumanMessage } from '@langchain/core/messages'; +import { bindClientTools, clientToolsChannel, clientToolsRouter } from './langgraph'; + +// A scripted fake chat model exposing the bindTools + invoke surface the graph uses. +class FakeModel { + bound: unknown[] = []; + private turn = 0; + bindTools(tools: unknown[]) { this.bound = tools; return this; } + async invoke(_messages: unknown[]) { + this.turn += 1; + if (this.turn === 1) { + return new AIMessage({ content: '', tool_calls: [{ name: 'get_weather', args: { city: 'SF' }, id: 'call_1' }] }); + } + return new AIMessage({ content: 'It is 65F in SF.' }); + } +} + +const State = Annotation.Root({ ...MessagesAnnotation.spec, ...clientToolsChannel() }); + +function buildGraph(model: FakeModel) { + const agent = async (state: typeof State.State) => { + const bound = bindClientTools(model, [], state); + const res = await (bound as FakeModel).invoke(state.messages); + return { messages: [res] }; + }; + return new StateGraph(State) + .addNode('agent', agent) + .addEdge('__start__', 'agent') + .addConditionalEdges('agent', (s) => clientToolsRouter([])(s), [END]) + .compile(); +} + +describe('client-tools loop (in-process)', () => { + it('binds the client stub, ends on the client call, then continues after a ToolMessage', async () => { + const model = new FakeModel(); + const graph = buildGraph(model); + const tools = [{ name: 'get_weather', description: 'Weather', parameters: { type: 'object' } }]; + + const r1 = await graph.invoke({ messages: [new HumanMessage('weather in SF?')], tools }); + const last1 = r1.messages[r1.messages.length - 1] as AIMessage; + expect(last1.tool_calls?.[0]?.name).toBe('get_weather'); + expect((model.bound[0] as { function: { name: string } }).function.name).toBe('get_weather'); + + const r2 = await graph.invoke({ + messages: [...r1.messages, new ToolMessage({ content: '65F', tool_call_id: 'call_1' })], + tools, + }); + expect((r2.messages[r2.messages.length - 1] as AIMessage).content).toBe('It is 65F in SF.'); + }); +}); From 911ecaabd5815d5b46c61c8dd6bf4ddc58bc1b3d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 15 Jun 2026 21:01:32 -0700 Subject: [PATCH 8/8] docs(middleware): README usage example --- libs/middleware/README.md | 89 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 84 insertions(+), 5 deletions(-) diff --git a/libs/middleware/README.md b/libs/middleware/README.md index 5c3efd5da..53f853363 100644 --- a/libs/middleware/README.md +++ b/libs/middleware/README.md @@ -1,9 +1,88 @@ # @threadplane/middleware -Backend middleware for the Threadplane client-tools capability. +Backend middleware for the [Threadplane](https://github.com/cacheplane/angular-agent-framework) +client-tools capability — frontend-declared tools the model calls and the browser executes. -## `@threadplane/middleware/langgraph` +The `@threadplane/middleware/langgraph` entrypoint is the LangGraph.js twin of the Python +`threadplane-middleware` package: it binds client-declared tool stubs onto your model and +routes client-tool-only turns to `END` so the browser executes them. -LangGraph.js helpers that bind frontend-declared client tools onto the model and route -client-tool-only turns to `END` so the browser executes them. See the -[client-tools guide](https://github.com/cacheplane/angular-agent-framework). +## How it works + +When a browser client sends a tool catalog (`{ name, description, parameters }` objects) +along with a run request, the graph exposes those tools to the model and routes their calls +back to the browser instead of executing them server-side. The browser executes the call and +re-runs the graph with a `ToolMessage` carrying the result. + +The catalog is read from `state.tools`, falling back to `state.client_tools` if `tools` is +absent. + +## Installation + +```bash +npm install @threadplane/middleware +# peer deps: +npm install @langchain/core @langchain/langgraph +``` + +## Usage + +```ts +import { Annotation, MessagesAnnotation, StateGraph, END } from '@langchain/langgraph'; +import { ChatOpenAI } from '@langchain/openai'; +import { + bindClientTools, + clientToolsChannel, + clientToolsRouter, +} from '@threadplane/middleware/langgraph'; + +// Declare the client-tools state channels (tools + client_tools) in one line. +const State = Annotation.Root({ ...MessagesAnnotation.spec, ...clientToolsChannel() }); + +const SERVER_TOOLS: unknown[] = []; // your server-owned tools (if any) +const baseLlm = new ChatOpenAI({ model: 'gpt-4o-mini' }); + +async function agent(state: typeof State.State) { + // Call bindClientTools per-run inside the node — the client catalog arrives + // in state and may differ between runs. + const llm = bindClientTools(baseLlm, SERVER_TOOLS, state); + const response = await llm.invoke(state.messages); + return { messages: [response] }; +} + +const graph = new StateGraph(State) + .addNode('agent', agent) + .addEdge('__start__', 'agent') + // clientToolsRouter binds the server tool names once; pass [] when there are none. + .addConditionalEdges('agent', clientToolsRouter([]), ['tools', END]) + .compile(); +``` + +### What happens with a client tool call + +1. The model emits a tool call whose name matches a client-declared tool. +2. `clientToolsRouter` (via `routeAfterAgent`) returns `"__end__"` — the run ends. +3. The browser receives the partial output, executes the tool locally, and re-runs the graph + with a `ToolMessage` containing the result. +4. The model continues from there as if it had called a server tool. + +A turn that mixes a server tool call and a client tool call routes to the **server** +destination first (the server tool runs; the client call surfaces on a later turn). + +### Lower-level helpers + +```ts +import { + clientToolSpecs, // → OpenAI function-tool objects for model.bindTools + clientToolNames, // → Set of client tool names + hasClientToolCall, // → boolean + hasServerToolCall, // → boolean (takes serverToolNames) + lastMessage, // → the last message from state.messages + routeAfterAgent, // → routing string (takes serverToolNames) +} from '@threadplane/middleware/langgraph'; +``` + +## Peer dependencies + +`@langchain/core` and `@langchain/langgraph`. The package has no runtime dependencies of its +own.