Skip to content

Commit 05fe378

Browse files
committed
feat: TanStack AI adapter for client-side tool calling (@simplepdf/embed/tanstack-ai)
1 parent 7203060 commit 05fe378

16 files changed

Lines changed: 357 additions & 31 deletions

.changeset/tanstack-ai-adapter.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@simplepdf/embed": minor
3+
"@simplepdf/react-embed-pdf": minor
4+
---
5+
6+
Add a TanStack AI adapter (the `/tanstack-ai` subpath) for client-side tool calling, alongside the existing Vercel AI SDK (`/ai-sdk`) adapter. Both wrap the same generated tool registry + bridge router, so the editor is drivable from either SDK with no duplicated logic.
7+
8+
- `@simplepdf/embed/tanstack-ai`: `simplePDFTanstackToolDefinitions()` (server, for `chat({ tools })`) and `createSimplePDFTanstackTools({ embed })` (browser `.client()` tools for `clientTools(...)` then `useChat({ tools })`).
9+
- `@simplepdf/react-embed-pdf/tanstack-ai`: `useEmbedTanstackTools(embedRef)`, the editor-bound client tools, plus the re-exported server definitions.
10+
- `@tanstack/ai` is a new optional peer, pulled only by the `/tanstack-ai` subpath; the package roots stay free of it (and of `zod`).

embed/README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,26 @@ const execute = createSimplePDFExecutor({ embed })
4949

5050
`@simplepdf/embed/tools` exposes the same registry SDK-agnostically (`routeToolCall`, `isSimplePDFToolName`). In React, `@simplepdf/react-embed-pdf/ai-sdk`'s `useEmbedTools(embedRef)` is the same registry pre-bound to the live editor.
5151

52+
For TanStack AI, the same registry is exposed via `@simplepdf/embed/tanstack-ai`:
53+
54+
```ts
55+
// server: execute-less definitions so the model is aware of the tools
56+
import { simplePDFTanstackToolDefinitions } from '@simplepdf/embed/tanstack-ai'
57+
chat({ adapter, messages, tools: simplePDFTanstackToolDefinitions() })
58+
59+
// browser: the same definitions bound to the live editor via .client()
60+
import { clientTools } from '@tanstack/ai-react'
61+
import { createSimplePDFTanstackTools } from '@simplepdf/embed/tanstack-ai'
62+
useChat({ connection, tools: clientTools(...createSimplePDFTanstackTools({ embed })) })
63+
```
64+
5265
## Install
5366

5467
```bash
5568
npm install @simplepdf/embed
5669
```
5770

58-
Zero runtime dependencies at the root. `zod` is an optional peer, needed only by the `/schemas`, `/tools`, and `/ai-sdk` subpaths. `/ai-sdk` produces values for the Vercel AI SDK but never imports `ai`; bring your own.
71+
Zero runtime dependencies at the root. `zod` is an optional peer, needed by the `/schemas`, `/tools`, `/ai-sdk`, and `/tanstack-ai` subpaths. `/ai-sdk` produces values for the Vercel AI SDK without importing `ai` (bring your own); `/tanstack-ai` uses `@tanstack/ai`'s `toolDefinition` (also an optional peer, pulled only by that subpath).
5972

6073
## Subpaths
6174

@@ -66,6 +79,7 @@ Zero runtime dependencies at the root. `zod` is an optional peer, needed only by
6679
| `@simplepdf/embed/schemas` | zod schema for every operation input | `zod` |
6780
| `@simplepdf/embed/tools` | SDK-agnostic agentic tool registry + `routeToolCall` + `isSimplePDFToolName` | `zod` |
6881
| `@simplepdf/embed/ai-sdk` | `simplePDFToolDefinitions()` (server) + `createSimplePDFExecutor({ embed })` (browser) for the Vercel AI SDK | `zod` |
82+
| `@simplepdf/embed/tanstack-ai` | `simplePDFTanstackToolDefinitions()` (server) + `createSimplePDFTanstackTools({ embed })` (browser) for TanStack AI | `zod`, `@tanstack/ai` |
6983

7084
## Where the editor goes
7185

embed/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@
4747
"types": "./dist/ai-sdk.d.ts",
4848
"import": "./dist/ai-sdk.js",
4949
"require": "./dist/ai-sdk.cjs"
50+
},
51+
"./tanstack-ai": {
52+
"types": "./dist/tanstack-ai.d.ts",
53+
"import": "./dist/tanstack-ai.js",
54+
"require": "./dist/tanstack-ai.cjs"
5055
}
5156
},
5257
"scripts": {
@@ -62,14 +67,19 @@
6267
"check:size": "npm run build && node scripts/check-bundle-size.mjs"
6368
},
6469
"peerDependencies": {
70+
"@tanstack/ai": "^0.38.0",
6571
"zod": "^4.0.0"
6672
},
6773
"peerDependenciesMeta": {
74+
"@tanstack/ai": {
75+
"optional": true
76+
},
6877
"zod": {
6978
"optional": true
7079
}
7180
},
7281
"devDependencies": {
82+
"@tanstack/ai": "^0.38.0",
7383
"jsdom": "^26.1.0",
7484
"tsup": "^8.5.1",
7585
"typescript": "^5.9.3",

embed/scripts/check-bundle-size.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const BUDGETS = {
1919
'schemas.js': 3 * 1024,
2020
'tools.js': 5 * 1024,
2121
'ai-sdk.js': 5.5 * 1024,
22+
'tanstack-ai.js': 5.5 * 1024,
2223
}
2324

2425
const localImports = (file) => {

embed/src/tanstack-ai.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// TanStack AI adapter. The same generated registry + bridge router as the Vercel
2+
// `/ai-sdk` adapter, in TanStack's isomorphic tool shape: server-registerable
3+
// definitions for `chat({ tools })`, and browser `.client()` tools bound to the live
4+
// editor for `clientTools(...)` -> `useChat({ tools })`. The zod input schemas drop
5+
// in directly (TanStack accepts any Standard Schema); `@tanstack/ai` is the only
6+
// added peer, pulled solely by this subpath.
7+
8+
import { type AnyClientTool, toolDefinition } from '@tanstack/ai'
9+
import { TOOL_DEFINITIONS, type SimplePDFToolName } from './generated/tools'
10+
import { isSimplePDFToolName, routeToolCall } from './tools'
11+
import type { Embed } from './types'
12+
13+
export type { SimplePDFToolName } from './generated/tools'
14+
15+
const TOOL_NAMES: readonly SimplePDFToolName[] = Object.keys(TOOL_DEFINITIONS).filter(isSimplePDFToolName)
16+
17+
// One shared definition per tool (name + description + zod input schema): the unit
18+
// `chat({ tools })` registers and `.client()` / `.server()` instantiate from.
19+
const define = (name: SimplePDFToolName) =>
20+
toolDefinition({
21+
name,
22+
description: TOOL_DEFINITIONS[name].description,
23+
inputSchema: TOOL_DEFINITIONS[name].inputSchema,
24+
})
25+
26+
// Server: execute-less definitions for `chat({ tools })`, so the model is aware of
27+
// the tools. A fresh array each call so the host can pick/omit (e.g. gate submit XOR
28+
// download) without mutating shared state.
29+
export const simplePDFTanstackToolDefinitions = (): ReturnType<typeof define>[] => TOOL_NAMES.map(define)
30+
31+
// Browser: the same definitions bound to the live editor via `.client()`, for
32+
// `clientTools(...)` -> `useChat({ tools })`. Each call validates input against the
33+
// tool schema and dispatches to the matching editor action, resolving to a BridgeResult.
34+
export const createSimplePDFTanstackTools = ({ embed }: { embed: Embed }): AnyClientTool[] =>
35+
TOOL_NAMES.map((name) => define(name).client((input) => routeToolCall(embed.actions, name, input)))

embed/test/helpers.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { vi } from 'vitest'
2+
import type { BridgeResult, Embed, IframeActions } from '../src/types'
3+
4+
const okResult: BridgeResult<unknown> = { success: true, data: null }
5+
6+
// A fully-stubbed actions group: every editor operation is a vi.fn resolving to a
7+
// success Result. Shared by the tools + adapter tests.
8+
export const makeActionsStub = (): IframeActions => {
9+
const method = (): Promise<BridgeResult<unknown>> => Promise.resolve(okResult)
10+
return {
11+
createField: vi.fn(method),
12+
deleteFields: vi.fn(method),
13+
deletePages: vi.fn(method),
14+
detectFields: vi.fn(method),
15+
download: vi.fn(method),
16+
focusField: vi.fn(method),
17+
getDocumentContent: vi.fn(method),
18+
getFields: vi.fn(method),
19+
goTo: vi.fn(method),
20+
loadDocument: vi.fn(method),
21+
movePage: vi.fn(method),
22+
rotatePage: vi.fn(method),
23+
selectTool: vi.fn(method),
24+
setFieldValue: vi.fn(method),
25+
submit: vi.fn(method),
26+
}
27+
}
28+
29+
// A minimal Embed handle wrapping stubbed actions (events + lifecycle are no-ops);
30+
// enough for adapters that only dispatch through embed.actions.
31+
export const makeEmbedStub = (): Embed => ({
32+
actions: makeActionsStub(),
33+
events: { on: () => () => {} },
34+
lifecycle: { dispose: () => {} },
35+
})

embed/test/tanstack-ai.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { createSimplePDFTanstackTools, simplePDFTanstackToolDefinitions } from '../src/tanstack-ai'
3+
import type { BridgeResult } from '../src/types'
4+
import { makeEmbedStub } from './helpers'
5+
6+
describe('simplePDFTanstackToolDefinitions', () => {
7+
it('returns the 14 agentic operations as execute-less definitions (loadDocument excluded)', () => {
8+
const definitions = simplePDFTanstackToolDefinitions()
9+
expect(definitions).toHaveLength(14)
10+
expect(definitions.map((definition) => definition.name)).not.toContain('loadDocument')
11+
for (const definition of definitions) {
12+
expect(typeof definition.description).toBe('string')
13+
expect(definition.inputSchema).toBeDefined()
14+
}
15+
})
16+
})
17+
18+
describe('createSimplePDFTanstackTools', () => {
19+
it('binds each tool to the editor: a client call validates input + dispatches to the matching action', async () => {
20+
const embed = makeEmbedStub()
21+
const goTo = createSimplePDFTanstackTools({ embed }).find((tool) => tool.name === 'goTo')
22+
if (goTo === undefined || goTo.execute === undefined) {
23+
throw new Error('expected a goTo client tool with an execute')
24+
}
25+
await goTo.execute({ page: 2 })
26+
expect(embed.actions.goTo).toHaveBeenCalledWith({ page: 2 })
27+
})
28+
29+
it('returns bad_request:invalid_input on schema-invalid input without dispatching', async () => {
30+
const embed = makeEmbedStub()
31+
const goTo = createSimplePDFTanstackTools({ embed }).find((tool) => tool.name === 'goTo')
32+
if (goTo === undefined || goTo.execute === undefined) {
33+
throw new Error('expected a goTo client tool with an execute')
34+
}
35+
const result: BridgeResult<unknown> = await goTo.execute({ page: 'not-a-number' })
36+
expect(result.success).toBe(false)
37+
if (!result.success) {
38+
expect(result.error.code).toBe('bad_request:invalid_input')
39+
}
40+
expect(embed.actions.goTo).not.toHaveBeenCalled()
41+
})
42+
})

embed/test/tools.test.ts

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,6 @@
1-
import { describe, expect, it, vi } from 'vitest'
1+
import { describe, expect, it } from 'vitest'
22
import { isSimplePDFToolName, routeToolCall, SIMPLEPDF_TOOLS } from '../src/tools'
3-
import type { BridgeResult, IframeActions } from '../src/types'
4-
5-
const okResult: BridgeResult<unknown> = { success: true, data: null }
6-
7-
const makeActionsStub = (): IframeActions => {
8-
const method = (): Promise<BridgeResult<unknown>> => Promise.resolve(okResult)
9-
return {
10-
createField: vi.fn(method),
11-
deleteFields: vi.fn(method),
12-
deletePages: vi.fn(method),
13-
detectFields: vi.fn(method),
14-
download: vi.fn(method),
15-
focusField: vi.fn(method),
16-
getDocumentContent: vi.fn(method),
17-
getFields: vi.fn(method),
18-
goTo: vi.fn(method),
19-
loadDocument: vi.fn(method),
20-
movePage: vi.fn(method),
21-
rotatePage: vi.fn(method),
22-
selectTool: vi.fn(method),
23-
setFieldValue: vi.fn(method),
24-
submit: vi.fn(method),
25-
}
26-
}
3+
import { makeActionsStub } from './helpers'
274

285
describe(isSimplePDFToolName.name, () => {
296
it('accepts agentic tool names', () => {

embed/tsup.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@ export default defineConfig({
1313
schemas: 'src/schemas.ts',
1414
tools: 'src/tools.ts',
1515
'ai-sdk': 'src/ai-sdk.ts',
16+
'tanstack-ai': 'src/tanstack-ai.ts',
1617
},
1718
format: ['esm', 'cjs'],
1819
dts: true,
1920
treeshake: true,
2021
splitting: true,
2122
sourcemap: true,
2223
clean: true,
23-
external: ['zod'],
24+
external: ['zod', '@tanstack/ai'],
2425
})

package-lock.json

Lines changed: 102 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)