feat: refactor node spec and add mcp tools (#244)

* refactor: carve out extraction panel

* refactor: create spec versions for node types

* refactor: create a GenericNode and remove custom nodes

* feat: add python and typescript sdk

* add dograh sdk

* fix: fetch draft workflow definition over published one

* fix: fix routes of SDKs to use code gen

* chore: remove doclink dependency to reduce image size

* chore: format files

* chore: bump pipecat

* feat: let mcp fetch archived workflows on demand

* chore: fix tests

* feat: add sdk documentation

* chore: change banner and add badge
This commit is contained in:
Abhishek 2026-04-21 07:56:16 +05:30 committed by GitHub
parent 0a61ef295f
commit 00a1a22b74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
162 changed files with 14355 additions and 3554 deletions

2
sdk/typescript/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
dist/
*.tsbuildinfo

24
sdk/typescript/LICENSE Normal file
View file

@ -0,0 +1,24 @@
BSD 2-Clause License
Copyright (c) 2025, Zansat Technologies Private Limited
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

91
sdk/typescript/README.md Normal file
View file

@ -0,0 +1,91 @@
# @dograh/sdk
Typed builder for Dograh voice-AI workflows. Fetches the node-spec catalog from
the Dograh backend at session start, validates every call against it at the
call site, and produces wire-format JSON that round-trips through the Python
`ReactFlowDTO`.
## Install
```bash
npm install @dograh/sdk
# or
pnpm add @dograh/sdk
```
For local development against a checked-out monorepo, add a tsconfig paths
entry:
```json
{
"paths": {
"@dograh/sdk": ["../sdk/typescript/src/index.ts"]
}
}
```
## Usage
```ts
import { DograhClient, Workflow } from "@dograh/sdk";
const client = new DograhClient({
baseUrl: "http://localhost:8000",
apiKey: process.env.DOGRAH_API_KEY,
});
const wf = new Workflow({ client, name: "loan_qualification" });
const start = await wf.add({
type: "startCall",
name: "greeting",
prompt: "You are Sarah from Acme Loans. Greet the caller warmly.",
greeting_type: "text",
greeting: "Hi {{first_name}}, this is Sarah.",
});
const qualify = await wf.add({
type: "agentNode",
name: "qualify",
prompt: "Ask about loan amount and timeline.",
});
const done = await wf.add({ type: "endCall", name: "done", prompt: "Thank them." });
wf.edge(start, qualify, { label: "interested", condition: "Caller expressed interest." });
wf.edge(qualify, done, { label: "done", condition: "Qualification complete." });
await client.saveWorkflow(123, wf);
```
## Client-side validation
Each `add()` call validates kwargs against the fetched spec. `ValidationError`
is thrown immediately when:
- an unknown field is passed (catches typos)
- a required field is missing or empty
- a scalar type is wrong (e.g., string for a boolean)
- an `options` value isn't in the allowed list
When a spec carries an `llm_hint`, the hint is appended to the error so an LLM
agent can self-correct on retry:
```
tool_uuids: expected tool_refs, got string
Hint: List of tool UUIDs from `list_tools`.
```
Server-side Pydantic validators run on save and surface anything the client
lets through.
## Environment
```bash
DOGRAH_API_URL=http://localhost:8000 # default
DOGRAH_API_KEY=sk-... # sent as X-API-Key
```
## License
BSD 2-Clause — see `LICENSE`.

389
sdk/typescript/package-lock.json generated Normal file
View file

@ -0,0 +1,389 @@
{
"name": "@dograh/sdk",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@dograh/sdk",
"version": "0.1.1",
"license": "BSD-2-Clause",
"devDependencies": {
"openapi-typescript": "^7.13.0",
"typescript": "^5.3.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@redocly/ajv": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
"integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js-replace": "^1.0.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@redocly/config": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz",
"integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@redocly/openapi-core": {
"version": "1.34.11",
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.11.tgz",
"integrity": "sha512-V09ayfnb5GyysmvARbt+voFZAjGcf7hSYxOYxSkCc4fbH/DTfq5YWoec8cflvmHHqyIFbqvmGKmYFzqhr9zxDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@redocly/ajv": "8.11.2",
"@redocly/config": "0.22.0",
"colorette": "1.4.0",
"https-proxy-agent": "7.0.6",
"js-levenshtein": "1.1.6",
"js-yaml": "4.1.1",
"minimatch": "5.1.9",
"pluralize": "8.0.0",
"yaml-ast-parser": "0.0.43"
},
"engines": {
"node": ">=18.17.0",
"npm": ">=9.5.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/change-case": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
"dev": true,
"license": "MIT"
},
"node_modules/colorette": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/index-to-position": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
"integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/js-levenshtein": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/openapi-typescript": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",
"integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@redocly/openapi-core": "^1.34.6",
"ansi-colors": "^4.1.3",
"change-case": "^5.4.4",
"parse-json": "^8.3.0",
"supports-color": "^10.2.2",
"yargs-parser": "^21.1.1"
},
"bin": {
"openapi-typescript": "bin/cli.js"
},
"peerDependencies": {
"typescript": "^5.x"
}
},
"node_modules/parse-json": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
"index-to-position": "^1.1.0",
"type-fest": "^4.39.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/supports-color": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/uri-js-replace": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
"integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
"dev": true,
"license": "MIT"
},
"node_modules/yaml-ast-parser": {
"version": "0.0.43",
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}

View file

@ -0,0 +1,52 @@
{
"name": "@dograh/sdk",
"version": "0.1.2",
"description": "Typed builder for Dograh voice-AI workflows",
"license": "BSD-2-Clause",
"author": "Zansat Technologies Private Limited",
"homepage": "https://dograh.com",
"repository": {
"type": "git",
"url": "https://github.com/dograh-hq/dograh.git",
"directory": "sdk/typescript"
},
"keywords": [
"dograh",
"voice-ai",
"workflow",
"sdk",
"llm",
"agent"
],
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./typed": {
"types": "./dist/typed/index.d.ts",
"import": "./dist/typed/index.js"
}
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"codegen": "node scripts/codegen.mts --api http://localhost:8000 --out src/typed",
"test": "tsc && node --test --test-reporter=spec tests/sdk.test.mts tests/typed.test.mts"
},
"engines": {
"node": ">=20"
},
"devDependencies": {
"openapi-typescript": "^7.13.0",
"typescript": "^5.3.0"
}
}

View file

@ -0,0 +1,258 @@
// Typed SDK code generator (TypeScript).
//
// Reads NodeSpecs from the live backend or a local JSON file and emits
// one `<kebab-case>.ts` per node type into `src/typed/` — each with a
// discriminated-union interface + a factory. The generated files are
// committed so `npm install @dograh/sdk` ships typed classes without
// requiring a regen step.
//
// Run via `npm run codegen` or:
//
// node scripts/codegen.mts --api http://localhost:8000 --out src/typed
// node scripts/codegen.mts --input specs.json --out src/typed
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
// ─── Spec types (structural; loaded at runtime via JSON) ──────────────────
interface PropertyOption {
value: string | number | boolean;
label: string;
description?: string;
}
interface PropertySpec {
name: string;
type: string;
display_name: string;
description: string;
llm_hint?: string | null;
default?: unknown;
required?: boolean;
options?: PropertyOption[];
properties?: PropertySpec[];
}
interface NodeSpec {
name: string;
display_name: string;
description: string;
llm_hint?: string | null;
category: string;
icon: string;
version: string;
properties: PropertySpec[];
}
// ─── Property type → TS type ──────────────────────────────────────────────
const SCALAR_TS_TYPES: Record<string, string> = {
string: "string",
number: "number",
boolean: "boolean",
json: "Record<string, unknown>",
mention_textarea: "string",
url: "string",
recording_ref: "string",
credential_ref: "string",
tool_refs: "string[]",
document_refs: "string[]",
};
function pascalCase(name: string): string {
// startCall → StartCall; agentNode → AgentNode
return name[0]!.toUpperCase() + name.slice(1);
}
function kebabCase(name: string): string {
// startCall → start-call; agentNode → agent-node
return name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
}
function literalUnion(options: PropertyOption[] | undefined): string {
if (!options || options.length === 0) return "string";
return options.map((o) => JSON.stringify(o.value)).join(" | ");
}
function tsTypeFor(prop: PropertySpec, ownerClass: string): string {
if (prop.type === "options") return literalUnion(prop.options);
if (prop.type === "multi_options") {
return `Array<${literalUnion(prop.options)}>`;
}
if (prop.type === "fixed_collection") {
return `Array<${ownerClass}${pascalCase(prop.name)}Row>`;
}
return SCALAR_TS_TYPES[prop.type] ?? "unknown";
}
// ─── JSDoc rendering ──────────────────────────────────────────────────────
function renderJsDoc(description: string, llmHint?: string | null, indent = 0): string {
const pad = " ".repeat(indent);
const body = [description, ...(llmHint ? ["", `LLM hint: ${llmHint}`] : [])]
.join("\n")
.split("\n")
.map((line) => `${pad} * ${line}`.trimEnd())
.join("\n");
return `${pad}/**\n${body}\n${pad} */`;
}
// ─── Source rendering ─────────────────────────────────────────────────────
function renderNestedRowInterface(
ownerClass: string,
parent: PropertySpec,
): string {
const rowClass = `${ownerClass}${pascalCase(parent.name)}Row`;
const props = parent.properties ?? [];
const lines: string[] = [];
lines.push(
renderJsDoc(parent.description ?? `Row in ${parent.name}.`, null),
);
lines.push(`export interface ${rowClass} {`);
for (const sub of props) {
if (sub.description) lines.push(renderJsDoc(sub.description, null, 4));
const annotation = tsTypeFor(sub, rowClass);
const optional = sub.required ? "" : "?";
lines.push(` ${sub.name}${optional}: ${annotation};`);
}
lines.push("}");
return lines.join("\n");
}
function renderSpecFile(spec: NodeSpec): string {
const className = pascalCase(spec.name);
const header = `// GENERATED — do not edit by hand.
//
// Regenerate with \`npm run codegen\` against the target Dograh backend.
// Source of truth: each node's NodeSpec in the backend's
// \`api/services/workflow/node_specs/\` directory.
`;
const nested: string[] = [];
for (const prop of spec.properties) {
if (prop.type === "fixed_collection") {
nested.push(renderNestedRowInterface(className, prop));
}
}
const classDoc = renderJsDoc(spec.description, spec.llm_hint);
const fieldLines: string[] = [];
fieldLines.push(` type: ${JSON.stringify(spec.name)};`);
for (const prop of spec.properties) {
if (prop.description) {
fieldLines.push(renderJsDoc(prop.description, prop.llm_hint, 4));
}
const annotation = tsTypeFor(prop, className);
// Required field (no spec default) has no `?`; everything else
// optional, the runtime SDK applies spec defaults.
const hasDefault = prop.default !== undefined && prop.default !== null;
const optional = prop.required && !hasDefault ? "" : "?";
fieldLines.push(` ${prop.name}${optional}: ${annotation};`);
}
const iface = `${classDoc}
export interface ${className} {
${fieldLines.join("\n")}
}`;
const factoryDoc = `/** Factory — sets \`type\` for you so you don't repeat the discriminator. */`;
const factory = `${factoryDoc}
export function ${spec.name}(input: Omit<${className}, "type">): ${className} {
return { type: ${JSON.stringify(spec.name)}, ...input };
}`;
return [header, ...nested, "", iface, "", factory, ""].join("\n");
}
function renderIndex(specs: NodeSpec[]): string {
const lines: string[] = [
"// GENERATED — do not edit by hand.",
"//",
"// Re-exports every typed node interface + factory. Also exports the",
"// `TypedNode` discriminated-union that `Workflow.addTyped` accepts.",
"",
];
const classNames: string[] = [];
for (const spec of specs.slice().sort((a, b) => a.name.localeCompare(b.name))) {
const className = pascalCase(spec.name);
const module = kebabCase(spec.name);
lines.push(
`export { type ${className}, ${spec.name} } from "./${module}.js";`,
);
classNames.push(className);
}
lines.push("");
lines.push("import type {");
for (const name of classNames) lines.push(` ${name},`);
lines.push('} from "./index.js";');
lines.push("");
lines.push("/** Discriminated union of every generated typed node. */");
lines.push(`export type TypedNode = ${classNames.join(" | ")};`);
lines.push("");
return lines.join("\n");
}
// ─── CLI ─────────────────────────────────────────────────────────────────
function parseArgs(argv: string[]): { api?: string; input?: string; out: string } {
let api: string | undefined;
let input: string | undefined;
let out = "";
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--api") api = argv[++i];
else if (a === "--input") input = argv[++i];
else if (a === "--out") out = argv[++i]!;
}
if (!out) throw new Error("--out is required");
if (!api && !input) throw new Error("Provide --api URL or --input PATH");
return { api, input, out };
}
async function loadSpecs(args: {
api?: string;
input?: string;
}): Promise<NodeSpec[]> {
if (args.api) {
const resp = await fetch(`${args.api.replace(/\/$/, "")}/api/v1/node-types`);
if (!resp.ok) {
throw new Error(
`GET /api/v1/node-types failed: ${resp.status} ${resp.statusText}`,
);
}
const body = (await resp.json()) as { node_types: NodeSpec[] };
return body.node_types ?? [];
}
const raw = JSON.parse(readFileSync(args.input!, "utf-8"));
if (Array.isArray(raw)) return raw as NodeSpec[];
if (raw && typeof raw === "object" && "node_types" in raw) {
return (raw as { node_types: NodeSpec[] }).node_types;
}
throw new Error("JSON must be an array or { node_types: [...] }");
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const specs = await loadSpecs(args);
mkdirSync(args.out, { recursive: true });
for (const spec of specs) {
const module = kebabCase(spec.name);
writeFileSync(join(args.out, `${module}.ts`), renderSpecFile(spec));
}
writeFileSync(join(args.out, "index.ts"), renderIndex(specs));
console.log(
`Generated ${specs.length} typed node modules (${specs
.map((s) => s.name)
.join(", ")}) into ${args.out}`,
);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -0,0 +1,96 @@
// GENERATED — do not edit. Source: filtered OpenAPI from `api.app`.
//
// Regenerate with `./scripts/generate_sdk.sh`.
//
// `DograhClient` extends this base to get HTTP methods for every route
// decorated with `sdk_expose(...)`. Request/response types come from
// `_generated_models` (openapi-typescript output, --root-types).
import type {
CredentialResponse,
DocumentListResponseSchema,
InitiateCallRequest,
NodeSpec,
NodeTypesResponse,
RecordingListResponseSchema,
ToolResponse,
UpdateWorkflowRequest,
WorkflowListResponse,
WorkflowResponse,
} from "./_generated_models.js";
export abstract class _GeneratedClient {
protected abstract request<T = unknown>(
method: string,
path: string,
opts?: { json?: unknown; params?: Record<string, unknown> },
): Promise<T>;
/** Fetch a single node spec by name. */
async getNodeType(name: string): Promise<NodeSpec> {
return this.request<NodeSpec>("GET", `/node-types/${name}`);
}
/** Get a single workflow by ID (returns draft if one exists, else published). */
async getWorkflow(workflowId: number): Promise<WorkflowResponse> {
return this.request<WorkflowResponse>("GET", `/workflow/fetch/${workflowId}`);
}
/** List webhook credentials available to the authenticated organization. */
async listCredentials(): Promise<CredentialResponse[]> {
return this.request<CredentialResponse[]>("GET", "/credentials/");
}
/** List knowledge base documents available to the authenticated organization. */
async listDocuments(opts: { status?: string; limit?: number; offset?: number } = {}): Promise<DocumentListResponseSchema> {
const params: Record<string, unknown> = {
...(opts.status !== undefined ? { "status": opts.status } : {}),
...(opts.limit !== undefined ? { "limit": opts.limit } : {}),
...(opts.offset !== undefined ? { "offset": opts.offset } : {}),
};
return this.request<DocumentListResponseSchema>("GET", "/knowledge-base/documents", { params });
}
/** List every registered node type with its spec. Pinned to spec_version. */
async listNodeTypes(): Promise<NodeTypesResponse> {
return this.request<NodeTypesResponse>("GET", "/node-types");
}
/** List workflow recordings available to the authenticated organization. */
async listRecordings(opts: { workflowId?: number; ttsProvider?: string; ttsModel?: string; ttsVoiceId?: string } = {}): Promise<RecordingListResponseSchema> {
const params: Record<string, unknown> = {
...(opts.workflowId !== undefined ? { "workflow_id": opts.workflowId } : {}),
...(opts.ttsProvider !== undefined ? { "tts_provider": opts.ttsProvider } : {}),
...(opts.ttsModel !== undefined ? { "tts_model": opts.ttsModel } : {}),
...(opts.ttsVoiceId !== undefined ? { "tts_voice_id": opts.ttsVoiceId } : {}),
};
return this.request<RecordingListResponseSchema>("GET", "/workflow-recordings/", { params });
}
/** List tools available to the authenticated organization. */
async listTools(opts: { status?: string; category?: string } = {}): Promise<ToolResponse[]> {
const params: Record<string, unknown> = {
...(opts.status !== undefined ? { "status": opts.status } : {}),
...(opts.category !== undefined ? { "category": opts.category } : {}),
};
return this.request<ToolResponse[]>("GET", "/tools/", { params });
}
/** List all workflows in the authenticated organization. */
async listWorkflows(opts: { status?: string } = {}): Promise<WorkflowListResponse[]> {
const params: Record<string, unknown> = {
...(opts.status !== undefined ? { "status": opts.status } : {}),
};
return this.request<WorkflowListResponse[]>("GET", "/workflow/fetch", { params });
}
/** Place a test call from a workflow to a phone number. */
async testPhoneCall(opts: { body: InitiateCallRequest }): Promise<unknown> {
return this.request("POST", "/telephony/initiate-call", { json: opts.body });
}
/** Update a workflow's name and/or definition. Saves as a new draft. */
async updateWorkflow(workflowId: number, opts: { body: UpdateWorkflowRequest }): Promise<WorkflowResponse> {
return this.request<WorkflowResponse>("PUT", `/workflow/${workflowId}`, { json: opts.body });
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,175 @@
// HTTP client for the Dograh REST API.
//
// Most endpoint methods come from `_GeneratedClient` (auto-generated from
// the FastAPI OpenAPI spec — see `scripts/generate_sdk.sh`). This class
// adds session/auth/caching around that base plus the ergonomic
// `loadWorkflow` / `saveWorkflow` wrappers that compose a generated call
// with local `Workflow` hydration.
import { _GeneratedClient } from "./_generated_client.js";
import type {
NodeSpec,
NodeTypesResponse,
UpdateWorkflowRequest,
WorkflowResponse,
} from "./_generated_models.js";
import { ApiError, SpecMismatchError } from "./errors.js";
import { Workflow, type SpecProvider } from "./workflow.js";
export interface DograhClientOptions {
baseUrl?: string;
apiKey?: string;
/** Request timeout in ms. */
timeoutMs?: number;
/** Optional fetch override for tests / custom transports. */
fetch?: typeof globalThis.fetch;
}
export class DograhClient extends _GeneratedClient implements SpecProvider {
readonly baseUrl: string;
readonly apiKey: string | undefined;
private readonly fetchImpl: typeof globalThis.fetch;
private readonly timeoutMs: number;
private readonly headers: Record<string, string>;
private readonly specCache = new Map<string, NodeSpec>();
private specVersionCache: string | null = null;
constructor(opts: DograhClientOptions = {}) {
super();
const rawBase =
opts.baseUrl ??
(typeof process !== "undefined" ? process.env.DOGRAH_API_URL : undefined) ??
"http://localhost:8000";
this.baseUrl = rawBase.replace(/\/+$/, "");
this.apiKey =
opts.apiKey ??
(typeof process !== "undefined" ? process.env.DOGRAH_API_KEY : undefined);
this.fetchImpl = opts.fetch ?? globalThis.fetch;
this.timeoutMs = opts.timeoutMs ?? 30_000;
this.headers = { Accept: "application/json" };
if (this.apiKey) this.headers["X-API-Key"] = this.apiKey;
}
/** Spec contract version reported by the server, or null until the
* first `listNodeTypes` / `getNodeType` call. */
get specVersion(): string | null {
return this.specVersionCache;
}
// ── spec discovery overrides (generated methods + caching) ────────
async listNodeTypes(): Promise<NodeTypesResponse> {
const resp = await super.listNodeTypes();
this.specVersionCache = resp.spec_version;
for (const spec of resp.node_types ?? []) {
this.specCache.set(spec.name, spec);
}
return resp;
}
async getNodeType(name: string): Promise<NodeSpec> {
const cached = this.specCache.get(name);
if (cached) return cached;
try {
const spec = await super.getNodeType(name);
this.specCache.set(name, spec);
return spec;
} catch (err) {
if (err instanceof ApiError && err.statusCode === 404) {
throw new SpecMismatchError(`Unknown node type: ${JSON.stringify(name)}`);
}
throw err;
}
}
// ── ergonomic workflow wrappers ───────────────────────────────────
/** Fetch a workflow and return it as an editable `Workflow` builder. */
async loadWorkflow(workflowId: number): Promise<Workflow> {
const resp = await this.getWorkflow(workflowId);
if (!resp.workflow_definition) {
throw new ApiError(
200,
`Workflow ${workflowId} has no definition to load`,
resp,
);
}
return Workflow.fromJson(
resp.workflow_definition as Parameters<typeof Workflow.fromJson>[0],
{ client: this, name: resp.name ?? "" },
);
}
async saveWorkflow(workflowId: number, workflow: Workflow): Promise<WorkflowResponse> {
const body: UpdateWorkflowRequest = {
name: workflow.name,
workflow_definition: workflow.toJson() as unknown as Record<string, unknown>,
};
return this.updateWorkflow(workflowId, { body });
}
// ── low-level (overrides `_GeneratedClient.request`) ──────────────
protected async request<T = unknown>(
method: string,
path: string,
opts?: { json?: unknown; params?: Record<string, unknown> },
): Promise<T> {
let url = `${this.baseUrl}/api/v1${path}`;
if (opts?.params) {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(opts.params)) {
if (v !== undefined && v !== null) qs.append(k, String(v));
}
const q = qs.toString();
if (q) url += (url.includes("?") ? "&" : "?") + q;
}
const hasBody = opts?.json !== undefined;
const init: RequestInit = {
method,
headers: {
...this.headers,
...(hasBody ? { "Content-Type": "application/json" } : {}),
},
body: hasBody ? JSON.stringify(opts!.json) : undefined,
};
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
init.signal = controller.signal;
let resp: Response;
try {
resp = await this.fetchImpl(url, init);
} finally {
clearTimeout(timer);
}
if (!resp.ok) {
let parsed: unknown;
let message = resp.statusText;
try {
parsed = await resp.json();
if (parsed && typeof parsed === "object") {
const p = parsed as Record<string, unknown>;
if (typeof p.detail === "string") message = p.detail;
else if (typeof p.message === "string") message = p.message;
}
} catch {
parsed = await resp.text().catch(() => "");
if (typeof parsed === "string" && parsed !== "") message = parsed;
}
throw new ApiError(resp.status, message, parsed);
}
if (resp.status === 204) return undefined as T;
const text = await resp.text();
if (text === "") return undefined as T;
try {
return JSON.parse(text) as T;
} catch {
return text as unknown as T;
}
}
}

View file

@ -0,0 +1,45 @@
// SDK-level exceptions. All subclass `DograhSdkError` so callers can
// catch them as one category.
export class DograhSdkError extends Error {
constructor(message: string) {
super(message);
this.name = "DograhSdkError";
}
}
/**
* Raised when node data fails client-side validation (unknown field,
* missing required field, obvious type mismatch).
*
* Server-side Pydantic validation runs on save and may raise further
* errors via `ApiError` this class covers the fast-fail cases caught
* at the `Workflow.add()` call site.
*/
export class ValidationError extends DograhSdkError {
constructor(message: string) {
super(message);
this.name = "ValidationError";
}
}
/** Raised when the Dograh backend returns a non-2xx response. */
export class ApiError extends DograhSdkError {
readonly statusCode: number;
readonly body: unknown;
constructor(statusCode: number, message: string, body?: unknown) {
super(`[${statusCode}] ${message}`);
this.name = "ApiError";
this.statusCode = statusCode;
this.body = body;
}
}
/** Raised when a referenced node type isn't registered on the server. */
export class SpecMismatchError extends DograhSdkError {
constructor(message: string) {
super(message);
this.name = "SpecMismatchError";
}
}

View file

@ -0,0 +1,59 @@
/**
* Dograh SDK typed builder for voice-AI workflows.
*
* Runtime SDK: fetches the spec catalog from the Dograh backend at session
* start and validates every `Workflow.add()` call against it. Don't import
* per-node-type classes the `type` argument is a string keyed against the
* fetched spec catalog.
*
* @example
* ```ts
* import { DograhClient, Workflow } from "@dograh/sdk";
*
* const client = new DograhClient({ baseUrl: "http://localhost:8000", apiKey: "..." });
* const wf = new Workflow({ client, name: "loan_qualification" });
*
* const start = await wf.add({
* type: "startCall",
* name: "greeting",
* prompt: "You are Sarah from Acme Loans...",
* });
* const done = await wf.add({ type: "endCall", name: "done", prompt: "Thank them." });
* wf.edge(start, done, { label: "done", condition: "Conversation wrapped." });
*
* await client.saveWorkflow(123, wf);
* ```
*/
export { DograhClient } from "./client.js";
export type { DograhClientOptions } from "./client.js";
export {
ApiError,
DograhSdkError,
SpecMismatchError,
ValidationError,
} from "./errors.js";
export type {
AddNodeOptions,
EdgeOptions,
SpecProvider,
WorkflowOptions,
} from "./workflow.js";
export { Workflow } from "./workflow.js";
export type {
DisplayOptions,
NodeCategory,
NodeRef,
NodeSpec,
PropertyOption,
PropertySpec,
PropertyType,
WireEdge,
WireNode,
WireWorkflow,
} from "./types.js";
// Typed SDK — generated per-node interfaces + factories. Importable as
// `import { startCall, type StartCall } from "@dograh/sdk/typed"` for
// tree-shaking, or via the `TypedNode` union here.
export type { TypedNode } from "./typed/index.js";

View file

@ -0,0 +1,77 @@
// GENERATED — do not edit by hand.
//
// Regenerate with `npm run codegen` against the target Dograh backend.
// Source of truth: each node's NodeSpec in the backend's
// `api/services/workflow/node_specs/` directory.
/**
* Each entry declares one variable to capture from the conversation, with its name, type, and per-variable hint.
*/
export interface AgentNodeExtraction_variablesRow {
/**
* snake_case identifier used downstream.
*/
name: string;
/**
* Data type of the extracted value.
*/
type: "string" | "number" | "boolean";
/**
* Per-variable hint describing what to look for.
*/
prompt?: string;
}
/**
* Conversational step the LLM runs one focused exchange.
*
* LLM hint: Mid-call step executed by the LLM. Most workflows are a chain of agent nodes connected by edges that describe transition conditions. Each agent node can invoke tools and reference documents.
*/
export interface AgentNode {
type: "agentNode";
/**
* Short identifier for this step (e.g., 'Qualify Budget'). Appears in call logs and edge transition tools.
*/
name?: string;
/**
* Agent system prompt for this step. Supports {{template_variables}} from extraction or pre-call fetch.
*/
prompt: string;
/**
* When true, the user can interrupt the agent mid-utterance. Set false for non-interruptible disclosures.
*/
allow_interrupt?: boolean;
/**
* When true and a Global node exists, prepends the global prompt to this node's prompt at runtime.
*/
add_global_prompt?: boolean;
/**
* When true, runs an LLM extraction pass on transition out of this node to capture variables from the conversation.
*/
extraction_enabled?: boolean;
/**
* Overall instructions guiding variable extraction.
*/
extraction_prompt?: string;
/**
* Each entry declares one variable to capture from the conversation, with its name, type, and per-variable hint.
*/
extraction_variables?: Array<AgentNodeExtraction_variablesRow>;
/**
* Tools the agent can invoke during this step.
*
* LLM hint: List of tool UUIDs from `list_tools`.
*/
tool_uuids?: string[];
/**
* Documents the agent can reference during this step.
*
* LLM hint: List of document UUIDs from `list_documents`.
*/
document_uuids?: string[];
}
/** Factory — sets `type` for you so you don't repeat the discriminator. */
export function agentNode(input: Omit<AgentNode, "type">): AgentNode {
return { type: "agentNode", ...input };
}

View file

@ -0,0 +1,61 @@
// GENERATED — do not edit by hand.
//
// Regenerate with `npm run codegen` against the target Dograh backend.
// Source of truth: each node's NodeSpec in the backend's
// `api/services/workflow/node_specs/` directory.
/**
* Each entry declares one variable to capture from the conversation, with its name, data type, and a per-variable extraction hint.
*/
export interface EndCallExtraction_variablesRow {
/**
* snake_case identifier used downstream.
*/
name: string;
/**
* The data type of the extracted value.
*/
type: "string" | "number" | "boolean";
/**
* Per-variable hint describing what to look for in the conversation.
*/
prompt?: string;
}
/**
* Closes the conversation and hangs up.
*
* LLM hint: Terminal node that politely closes the conversation. Variable extraction can run before hangup. A workflow can have multiple endCall nodes reached via different edge conditions.
*/
export interface EndCall {
type: "endCall";
/**
* Short identifier shown in call logs. Should describe the ending context (e.g., 'Successful close', 'Polite decline').
*/
name?: string;
/**
* Agent system prompt for the closing exchange. Supports {{template_variables}} from extraction or pre-call fetch.
*/
prompt: string;
/**
* When true and a Global node exists, prepends the global prompt to this node's prompt at runtime.
*/
add_global_prompt?: boolean;
/**
* When true, runs an LLM extraction pass before hangup to capture variables from the conversation.
*/
extraction_enabled?: boolean;
/**
* Overall instructions guiding how variables should be extracted from the conversation.
*/
extraction_prompt?: string;
/**
* Each entry declares one variable to capture from the conversation, with its name, data type, and a per-variable extraction hint.
*/
extraction_variables?: Array<EndCallExtraction_variablesRow>;
}
/** Factory — sets `type` for you so you don't repeat the discriminator. */
export function endCall(input: Omit<EndCall, "type">): EndCall {
return { type: "endCall", ...input };
}

View file

@ -0,0 +1,28 @@
// GENERATED — do not edit by hand.
//
// Regenerate with `npm run codegen` against the target Dograh backend.
// Source of truth: each node's NodeSpec in the backend's
// `api/services/workflow/node_specs/` directory.
/**
* Persona/tone appended to every agent node's prompt.
*
* LLM hint: System-level prompt appended to every prompted node whose `add_global_prompt` is true. Use it for persona, tone, and shared rules that apply across the entire conversation. At most one global node per workflow.
*/
export interface GlobalNode {
type: "globalNode";
/**
* Short identifier shown in the canvas and call logs. Has no runtime effect.
*/
name?: string;
/**
* Text appended to every prompted node's system prompt when that node has `add_global_prompt=true`. Supports {{template_variables}}.
*/
prompt?: string;
}
/** Factory — sets `type` for you so you don't repeat the discriminator. */
export function globalNode(input: Omit<GlobalNode, "type">): GlobalNode {
return { type: "globalNode", ...input };
}

View file

@ -0,0 +1,25 @@
// GENERATED — do not edit by hand.
//
// Re-exports every typed node interface + factory. Also exports the
// `TypedNode` discriminated-union that `Workflow.addTyped` accepts.
export { type AgentNode, agentNode } from "./agent-node.js";
export { type EndCall, endCall } from "./end-call.js";
export { type GlobalNode, globalNode } from "./global-node.js";
export { type Qa, qa } from "./qa.js";
export { type StartCall, startCall } from "./start-call.js";
export { type Trigger, trigger } from "./trigger.js";
export { type Webhook, webhook } from "./webhook.js";
import type {
AgentNode,
EndCall,
GlobalNode,
Qa,
StartCall,
Trigger,
Webhook,
} from "./index.js";
/** Discriminated union of every generated typed node. */
export type TypedNode = AgentNode | EndCall | GlobalNode | Qa | StartCall | Trigger | Webhook;

View file

@ -0,0 +1,64 @@
// GENERATED — do not edit by hand.
//
// Regenerate with `npm run codegen` against the target Dograh backend.
// Source of truth: each node's NodeSpec in the backend's
// `api/services/workflow/node_specs/` directory.
/**
* Run LLM quality analysis on the call transcript.
*
* LLM hint: Runs an LLM quality review on the call transcript after completion. Per-node analysis splits the conversation by node and evaluates each segment against the configured system prompt. Sampling, minimum duration, and voicemail filters are supported.
*/
export interface Qa {
type: "qa";
/**
* Short identifier for this QA configuration.
*/
name?: string;
/**
* When false, the QA run is skipped.
*/
qa_enabled?: boolean;
/**
* Instructions to the QA reviewer LLM. Supports placeholders: `{node_summary}`, `{previous_conversation_summary}`, `{transcript}`, `{metrics}`.
*/
qa_system_prompt?: string;
/**
* Calls shorter than this are skipped.
*/
qa_min_call_duration?: number;
/**
* When false, calls flagged as voicemail are skipped.
*/
qa_voicemail_calls?: boolean;
/**
* Percent of eligible calls QA'd. 100 means every call; lower values use random sampling.
*/
qa_sample_rate?: number;
/**
* When true, the QA pass uses the same LLM the workflow runs with. Set false to specify a separate provider/model.
*/
qa_use_workflow_llm?: boolean;
/**
* LLM provider used for the QA pass.
*/
qa_provider?: "openai" | "azure" | "openrouter" | "anthropic";
/**
* Model identifier (e.g., 'gpt-4o', 'claude-sonnet-4-6'). Provider-specific.
*/
qa_model?: string;
/**
* API key for the chosen provider.
*/
qa_api_key?: string;
/**
* Required for the Azure provider.
*/
qa_endpoint?: string;
}
/** Factory — sets `type` for you so you don't repeat the discriminator. */
export function qa(input: Omit<Qa, "type">): Qa {
return { type: "qa", ...input };
}

View file

@ -0,0 +1,113 @@
// GENERATED — do not edit by hand.
//
// Regenerate with `npm run codegen` against the target Dograh backend.
// Source of truth: each node's NodeSpec in the backend's
// `api/services/workflow/node_specs/` directory.
/**
* Each entry declares one variable to capture, with its name, data type, and per-variable extraction hint.
*/
export interface StartCallExtraction_variablesRow {
/**
* snake_case identifier used downstream.
*/
name: string;
/**
* Data type of the extracted value.
*/
type: "string" | "number" | "boolean";
/**
* Per-variable hint describing what to look for.
*/
prompt?: string;
}
/**
* Entry point of the workflow plays a greeting and opens the conversation.
*
* LLM hint: The entry point of every workflow (exactly one required). Plays an optional greeting, can fetch context from an external API before the call begins, and executes the first conversational turn.
*/
export interface StartCall {
type: "startCall";
/**
* Short identifier shown in the canvas and call logs.
*/
name?: string;
/**
* Whether the optional greeting is spoken via TTS from text or played from a pre-recorded audio file.
*/
greeting_type?: "text" | "audio";
/**
* Text spoken via TTS at the start of the call. Supports {{template_variables}}. Leave empty to skip the greeting.
*/
greeting?: string;
/**
* Pre-recorded audio file played at the start of the call.
*
* LLM hint: Value is the `recording_id` string. Use the `list_recordings` MCP tool to discover available recordings.
*/
greeting_recording_id?: string;
/**
* Agent system prompt for the opening turn. Supports {{template_variables}} from pre-call fetch and the initial context.
*/
prompt: string;
/**
* When true, the user can interrupt the agent mid-utterance.
*/
allow_interrupt?: boolean;
/**
* When true and a Global node exists, prepends the global prompt to this node's prompt at runtime.
*/
add_global_prompt?: boolean;
/**
* When true, the agent waits before speaking after pickup. Useful for outbound calls where the called party needs a moment to settle.
*/
delayed_start?: boolean;
/**
* Seconds to wait before the agent speaks. 0.110.
*/
delayed_start_duration?: number;
/**
* When true, runs an LLM extraction pass on transition out of this node to capture variables from the opening turn.
*/
extraction_enabled?: boolean;
/**
* Overall instructions guiding variable extraction.
*/
extraction_prompt?: string;
/**
* Each entry declares one variable to capture, with its name, data type, and per-variable extraction hint.
*/
extraction_variables?: Array<StartCallExtraction_variablesRow>;
/**
* Tools the agent can invoke during the opening turn.
*
* LLM hint: List of tool UUIDs from `list_tools`.
*/
tool_uuids?: string[];
/**
* Documents the agent can reference.
*
* LLM hint: List of document UUIDs from `list_documents`.
*/
document_uuids?: string[];
/**
* When true, makes a POST request to an external API before the call starts and merges the JSON response into the call context as template variables.
*/
pre_call_fetch_enabled?: boolean;
/**
* URL the pre-call POST request is sent to. The request body includes caller and called numbers.
*/
pre_call_fetch_url?: string;
/**
* Optional credential attached to the pre-call request.
*
* LLM hint: Credential UUID from `list_credentials`.
*/
pre_call_fetch_credential_uuid?: string;
}
/** Factory — sets `type` for you so you don't repeat the discriminator. */
export function startCall(input: Omit<StartCall, "type">): StartCall {
return { type: "startCall", ...input };
}

View file

@ -0,0 +1,32 @@
// GENERATED — do not edit by hand.
//
// Regenerate with `npm run codegen` against the target Dograh backend.
// Source of truth: each node's NodeSpec in the backend's
// `api/services/workflow/node_specs/` directory.
/**
* Public HTTP endpoint that launches the workflow.
*
* LLM hint: Exposes a public HTTP POST endpoint. External systems call the URL (derived from the auto-generated `trigger_path`) to launch this workflow. Requires an API key in the `X-API-Key` header.
*/
export interface Trigger {
type: "trigger";
/**
* Short identifier shown in the canvas. No runtime effect.
*/
name?: string;
/**
* When false, the trigger URL returns 404.
*/
enabled?: boolean;
/**
* Auto-generated UUID-style path segment that uniquely identifies this trigger. Do not edit manually.
*/
trigger_path?: string;
}
/** Factory — sets `type` for you so you don't repeat the discriminator. */
export function trigger(input: Omit<Trigger, "type">): Trigger {
return { type: "trigger", ...input };
}

View file

@ -0,0 +1,67 @@
// GENERATED — do not edit by hand.
//
// Regenerate with `npm run codegen` against the target Dograh backend.
// Source of truth: each node's NodeSpec in the backend's
// `api/services/workflow/node_specs/` directory.
/**
* Additional HTTP headers to include with the request.
*/
export interface WebhookCustom_headersRow {
/**
* HTTP header name (e.g., 'X-Source').
*/
key: string;
/**
* Header value (supports {{template_variables}}).
*/
value: string;
}
/**
* Send HTTP request after the workflow completes.
*
* LLM hint: Sends an HTTP request to an external system after the workflow completes. The payload is a Jinja-templated JSON body with access to `workflow_run_id`, `initial_context`, `gathered_context`, `annotations`, and call metadata.
*/
export interface Webhook {
type: "webhook";
/**
* Short identifier shown in the canvas and run logs.
*/
name?: string;
/**
* When false, the webhook is skipped at run time.
*/
enabled?: boolean;
/**
* HTTP verb used for the outbound request.
*/
http_method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
/**
* URL the request is sent to.
*/
endpoint_url?: string;
/**
* Optional credential applied as the Authorization header.
*
* LLM hint: Credential UUID from `list_credentials`.
*/
credential_uuid?: string;
/**
* Additional HTTP headers to include with the request.
*/
custom_headers?: Array<WebhookCustom_headersRow>;
/**
* JSON body of the request. Values are Jinja-rendered against the run context `{{workflow_run_id}}`, `{{gathered_context.foo}}`, `{{annotations.qa_xxx}}`, etc.
*/
payload_template?: Record<string, unknown>;
/**
* Optional retry settings: `enabled` (bool), `max_retries` (int), `retry_delay_seconds` (int).
*/
retry_config?: Record<string, unknown>;
}
/** Factory — sets `type` for you so you don't repeat the discriminator. */
export function webhook(input: Omit<Webhook, "type">): Webhook {
return { type: "webhook", ...input };
}

101
sdk/typescript/src/types.ts Normal file
View file

@ -0,0 +1,101 @@
// Structural types mirroring the NodeSpec schema served by the Dograh
// backend at /api/v1/node-types. Kept local (no dependency on the UI's
// generated client) so this package is self-contained and publishable.
export type PropertyType =
| "string"
| "number"
| "boolean"
| "options"
| "multi_options"
| "fixed_collection"
| "json"
| "tool_refs"
| "document_refs"
| "recording_ref"
| "credential_ref"
| "mention_textarea"
| "url";
export interface PropertyOption {
value: string | number | boolean;
label: string;
description?: string | null;
}
export interface DisplayOptions {
show?: Record<string, unknown[]> | null;
hide?: Record<string, unknown[]> | null;
}
export interface PropertySpec {
name: string;
type: PropertyType;
display_name: string;
description: string;
llm_hint?: string | null;
default?: unknown;
required?: boolean;
placeholder?: string | null;
display_options?: DisplayOptions | null;
options?: PropertyOption[] | null;
properties?: PropertySpec[] | null;
min_value?: number | null;
max_value?: number | null;
min_length?: number | null;
max_length?: number | null;
pattern?: string | null;
editor?: string | null;
extra?: Record<string, unknown>;
}
export type NodeCategory =
| "call_node"
| "global_node"
| "trigger"
| "integration";
export interface NodeSpec {
name: string;
display_name: string;
description: string;
llm_hint?: string | null;
category: NodeCategory;
icon: string;
version: string;
properties: PropertySpec[];
examples?: Array<{
name: string;
description?: string | null;
data: Record<string, unknown>;
}>;
// migrations and graph_constraints exist on the wire but aren't
// needed for the SDK's client-side validation — intentionally omitted.
}
/** Opaque handle returned by `Workflow.add()` and passed to `edge()`. */
export interface NodeRef {
id: string;
type: string;
}
/** Wire-format shapes matching `ReactFlowDTO` in the backend. */
export interface WireNode {
id: string;
type: string;
position: { x: number; y: number };
data: Record<string, unknown>;
}
export interface WireEdge {
id: string;
source: string;
target: string;
data: Record<string, unknown>;
}
export interface WireWorkflow {
nodes: WireNode[];
edges: WireEdge[];
viewport: { x: number; y: number; zoom: number };
}

View file

@ -0,0 +1,157 @@
// Client-side validation of node data against a fetched spec. Mirrors
// `sdk/python/src/dograh_sdk/_validation.py` byte-for-byte where possible
// so the two SDKs raise identical error messages for identical bad input.
//
// Intentionally lightweight: catch typos / missing required / obvious
// scalar mismatches at the call site; leave rigorous coercion to the
// backend Pydantic validators at save time.
import { ValidationError } from "./errors.js";
import type { NodeSpec, PropertySpec } from "./types.js";
// PropertyType → expected JS typeof values (after accounting for `null`
// and arrays). `null` here means "skip scalar-type check" (compound
// types, refs, JSON, etc.).
const SCALAR_TYPES: Record<string, ReadonlyArray<string> | null> = {
string: ["string"],
number: ["number"],
boolean: ["boolean"],
options: null,
multi_options: null,
fixed_collection: ["array"],
json: null,
tool_refs: ["array"],
document_refs: ["array"],
recording_ref: ["string"],
credential_ref: ["string"],
mention_textarea: ["string"],
url: ["string"],
};
function jsTypeOf(value: unknown): string {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
return typeof value;
}
function withHint(prop: PropertySpec, message: string): string {
return prop.llm_hint ? `${message}\n Hint: ${prop.llm_hint}` : message;
}
function checkScalar(prop: PropertySpec, value: unknown): void {
if (value === undefined || value === null) return;
const allowed = SCALAR_TYPES[prop.type];
if (!allowed) return;
const got = jsTypeOf(value);
if (!allowed.includes(got)) {
throw new ValidationError(
withHint(prop, `${prop.name}: expected ${prop.type}, got ${got}`),
);
}
}
function checkOptions(prop: PropertySpec, value: unknown): void {
if (value === undefined || value === null) return;
const allowed = new Set((prop.options ?? []).map((o) => o.value));
if (allowed.size === 0) return;
if (prop.type === "multi_options") {
if (!Array.isArray(value)) {
throw new ValidationError(
withHint(
prop,
`${prop.name}: expected list, got ${jsTypeOf(value)}`,
),
);
}
const bad = value.filter(
(v) => !allowed.has(v as string | number | boolean),
);
if (bad.length > 0) {
throw new ValidationError(
withHint(
prop,
`${prop.name}: values ${JSON.stringify(bad)} not in allowed ${JSON.stringify(
[...allowed].sort(),
)}`,
),
);
}
} else if (!allowed.has(value as string | number | boolean)) {
throw new ValidationError(
withHint(
prop,
`${prop.name}: ${JSON.stringify(value)} not in allowed ${JSON.stringify(
[...allowed].sort(),
)}`,
),
);
}
}
export function validateNodeData(
spec: NodeSpec | { name: string; properties: PropertySpec[] },
kwargs: Record<string, unknown>,
): Record<string, unknown> {
const declared = new Map(spec.properties.map((p) => [p.name, p]));
// Unknown field names — the most common LLM hallucination.
const unknown = Object.keys(kwargs).filter((k) => !declared.has(k));
if (unknown.length > 0) {
throw new ValidationError(
`${spec.name}: unknown field(s) ${JSON.stringify(unknown.sort())}. ` +
`Allowed: ${JSON.stringify([...declared.keys()].sort())}`,
);
}
const data: Record<string, unknown> = {};
for (const [name, prop] of declared) {
let value: unknown;
if (name in kwargs) {
value = kwargs[name];
} else if (prop.default !== undefined && prop.default !== null) {
value = prop.default;
} else {
value = undefined;
}
if (prop.type === "options" || prop.type === "multi_options") {
checkOptions(prop, value);
} else {
checkScalar(prop, value);
}
// Nested fixed_collection rows — validate each row as a sub-spec.
if (prop.type === "fixed_collection" && Array.isArray(value)) {
const subSpec = {
name: `${spec.name}.${name}`,
properties: prop.properties ?? [],
};
data[name] = value.map((row) =>
validateNodeData(subSpec, row as Record<string, unknown>),
);
continue;
}
if (value !== undefined) data[name] = value;
}
// Required check — must be set AND non-empty for strings.
for (const [name, prop] of declared) {
if (!prop.required) continue;
const val = data[name];
if (
val === undefined ||
val === null ||
(typeof val === "string" && val.trim() === "")
) {
throw new ValidationError(
withHint(
prop,
`${spec.name}: required field missing: ${name}`,
),
);
}
}
return data;
}

View file

@ -0,0 +1,194 @@
// Workflow builder mirroring `sdk/python/src/dograh_sdk/workflow.py`.
//
// Users compose workflows via `workflow.add({ type: "agentNode", ... })`
// and `workflow.edge(source, target, ...)`. Each `add()` call is
// validated against the fetched spec immediately, so LLM hallucinations
// fail at the call site rather than at save time.
//
// Wire format matches `ReactFlowDTO` from the backend 1:1 — `toJson()`
// output round-trips through `ReactFlowDTO.model_validate` unchanged.
import type { NodeSpec } from "./_generated_models.js";
import { ValidationError } from "./errors.js";
import type { NodeRef, WireEdge, WireNode, WireWorkflow } from "./types.js";
import { validateNodeData } from "./validation.js";
/** Minimal interface the Workflow builder needs from a client. Any object
* satisfying this shape works (real HTTP client, in-memory stub, etc.). */
export interface SpecProvider {
getNodeType(name: string): Promise<NodeSpec>;
}
export interface WorkflowOptions {
client: SpecProvider;
name?: string;
description?: string;
}
export interface AddNodeOptions {
type: string;
position?: [number, number];
/** Remaining node data fields are validated against the spec. */
[key: string]: unknown;
}
export interface EdgeOptions {
label: string;
condition: string;
transitionSpeech?: string;
transitionSpeechType?: "text" | "audio";
transitionSpeechRecordingId?: string;
}
export class Workflow {
readonly name: string;
readonly description: string;
private readonly client: SpecProvider;
private readonly nodes: WireNode[] = [];
private readonly edges: WireEdge[] = [];
// Auto-incrementing IDs match the pattern used by the existing UI.
private nextNodeId = 1;
constructor(opts: WorkflowOptions) {
this.client = opts.client;
this.name = opts.name ?? "";
this.description = opts.description ?? "";
}
/**
* Add a node of the given type.
*
* `type` is a spec name (e.g., "startCall", "agentNode"). Remaining
* properties are validated against the spec unknown or missing
* required fields throw `ValidationError` immediately.
*/
async add(opts: AddNodeOptions): Promise<NodeRef> {
const { type, position, ...rest } = opts;
const spec = await this.client.getNodeType(type);
const data = validateNodeData(spec, rest);
const nodeId = String(this.nextNodeId++);
const [x, y] = position ?? [0, 0];
this.nodes.push({
id: nodeId,
type,
position: { x, y },
data,
});
return { id: nodeId, type };
}
/**
* Typed variant of `add()` takes a typed node object from
* `@dograh/sdk/typed` (or its discriminated-union form) instead of
* raw kwargs.
*
* Equivalent to:
* const { type, ...rest } = node;
* wf.add({ type, position, ...rest });
*
* Benefits: TS narrows the allowed fields per `type` at edit time,
* and IDEs surface the spec's description + llm_hint as JSDoc.
*/
async addTyped<T extends { type: string }>(
node: T,
opts?: { position?: [number, number] },
): Promise<NodeRef> {
const { type, ...rest } = node as unknown as { type: string } & Record<
string,
unknown
>;
return this.add({ type, position: opts?.position, ...rest });
}
/**
* Connect two nodes with a labeled transition.
*
* `label` identifies the branch in call logs and LLM tool schemas;
* `condition` is the natural-language predicate the engine evaluates
* to decide when to follow the edge.
*/
edge(source: NodeRef, target: NodeRef, opts: EdgeOptions): void {
if (!opts.label || opts.label.trim() === "") {
throw new ValidationError("edge.label is required");
}
if (!opts.condition || opts.condition.trim() === "") {
throw new ValidationError("edge.condition is required");
}
const data: Record<string, unknown> = {
label: opts.label,
condition: opts.condition,
};
if (opts.transitionSpeech !== undefined) {
data.transition_speech = opts.transitionSpeech;
}
if (opts.transitionSpeechType !== undefined) {
data.transition_speech_type = opts.transitionSpeechType;
}
if (opts.transitionSpeechRecordingId !== undefined) {
data.transition_speech_recording_id = opts.transitionSpeechRecordingId;
}
this.edges.push({
id: `${source.id}-${target.id}`,
source: source.id,
target: target.id,
data,
});
}
/** Serialize to the `ReactFlowDTO` wire format. */
toJson(): WireWorkflow {
return {
nodes: this.nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
edges: this.edges.map((e) => ({ ...e, data: { ...e.data } })),
viewport: { x: 0, y: 0, zoom: 1 },
};
}
/**
* Rebuild a Workflow from a stored `workflow_json` payload. Useful
* for the "view/edit as code" flow: fetch existing workflow, convert
* to SDK objects, let the LLM mutate in code, serialize back.
*/
static async fromJson(
payload: { nodes?: WireNode[]; edges?: WireEdge[] } & Record<string, unknown>,
opts: WorkflowOptions,
): Promise<Workflow> {
const wf = new Workflow(opts);
for (const raw of payload.nodes ?? []) {
const spec = await wf.client.getNodeType(raw.type);
const validated = validateNodeData(spec, raw.data ?? {});
wf.nodes.push({
id: String(raw.id),
type: raw.type,
position: raw.position ?? { x: 0, y: 0 },
data: validated,
});
}
// Keep ID generator above the highest numeric ID seen so new
// nodes don't collide with existing ones.
const numericIds = wf.nodes
.map((n) => Number(n.id))
.filter((n) => Number.isInteger(n));
wf.nextNodeId = (numericIds.length > 0 ? Math.max(...numericIds) : 0) + 1;
for (const raw of payload.edges ?? []) {
wf.edges.push({
id: String(raw.id ?? `${raw.source}-${raw.target}`),
source: String(raw.source),
target: String(raw.target),
data: raw.data ?? {},
});
}
return wf;
}
/** Find a NodeRef by ID. Useful after `fromJson` to reference
* pre-existing nodes when building new edges. */
findNode(id: string): NodeRef | null {
const found = this.nodes.find((n) => n.id === id);
return found ? { id: found.id, type: found.type } : null;
}
}

View file

@ -0,0 +1,423 @@
// Unit tests for @dograh/sdk. Uses Node's built-in `node:test` runner and
// an in-memory spec stub — no HTTP, no backend dependency. Mirrors the
// Python SDK tests in api/tests/test_dograh_sdk.py.
//
// Run via `npm test` in sdk/typescript/.
import { describe, it } from "node:test";
import assert from "node:assert/strict";
// Import the BUILT artifact — same shape consumers get from `npm install`.
// `npm test` runs `tsc` first so dist/ is fresh.
import {
ApiError,
DograhClient,
SpecMismatchError,
ValidationError,
Workflow,
} from "../dist/index.js";
import type { NodeSpec, SpecProvider } from "../dist/index.js";
// ─── Minimal fixture specs (enough to cover the SDK's code paths) ─────────
const SPECS: Record<string, NodeSpec> = {
startCall: {
name: "startCall",
display_name: "Start Call",
description: "Entry point.",
category: "call_node",
icon: "Play",
version: "1.0.0",
properties: [
{
name: "name",
type: "string",
display_name: "Name",
description: "n",
required: true,
default: "Start Call",
},
{
name: "prompt",
type: "mention_textarea",
display_name: "Prompt",
description: "p",
required: true,
},
{
name: "allow_interrupt",
type: "boolean",
display_name: "Allow Interrupt",
description: "a",
default: false,
},
{
name: "greeting_type",
type: "options",
display_name: "Greeting Type",
description: "g",
default: "text",
options: [
{ value: "text", label: "Text" },
{ value: "audio", label: "Audio" },
],
},
],
},
agentNode: {
name: "agentNode",
display_name: "Agent",
description: "Mid-call step.",
category: "call_node",
icon: "Headset",
version: "1.0.0",
properties: [
{
name: "name",
type: "string",
display_name: "Name",
description: "n",
required: true,
},
{
name: "prompt",
type: "mention_textarea",
display_name: "Prompt",
description: "p",
required: true,
},
{
name: "allow_interrupt",
type: "boolean",
display_name: "Allow",
description: "a",
default: true,
},
{
name: "tool_uuids",
type: "tool_refs",
display_name: "Tools",
description: "Tools the agent can invoke.",
llm_hint: "List of tool UUIDs from `list_tools`.",
},
],
},
endCall: {
name: "endCall",
display_name: "End",
description: "Terminal.",
category: "call_node",
icon: "OctagonX",
version: "1.0.0",
properties: [
{
name: "name",
type: "string",
display_name: "Name",
description: "n",
required: true,
},
{
name: "prompt",
type: "mention_textarea",
display_name: "Prompt",
description: "p",
required: true,
},
],
},
};
class StubClient implements SpecProvider {
async getNodeType(name: string): Promise<NodeSpec> {
const spec = SPECS[name];
if (!spec) throw new SpecMismatchError(`Unknown spec: ${name}`);
return spec;
}
}
const client = new StubClient();
// ─── Builder + toJson round-trip ──────────────────────────────────────────
describe("Workflow builder", () => {
it("builds a minimal workflow and serializes the wire shape", async () => {
const wf = new Workflow({ client, name: "minimal" });
const start = await wf.add({
type: "startCall",
name: "greeting",
prompt: "Say hi.",
});
const end = await wf.add({
type: "endCall",
name: "close",
prompt: "Thank them.",
});
wf.edge(start, end, { label: "done", condition: "All greeted." });
const payload = wf.toJson();
assert.equal(payload.nodes.length, 2);
assert.deepEqual(
payload.nodes.map((n) => n.type).sort(),
["endCall", "startCall"],
);
assert.equal(payload.edges.length, 1);
const edge = payload.edges[0]!;
assert.equal(edge.source, start.id);
assert.equal(edge.target, end.id);
});
it("applies spec defaults when fields are omitted", async () => {
const wf = new Workflow({ client });
const start = await wf.add({
type: "startCall",
name: "g",
prompt: "hi",
});
const data = wf.toJson().nodes[0]!.data;
assert.equal(data.allow_interrupt, false);
assert.equal(data.greeting_type, "text");
assert.ok(start.id);
});
});
// ─── Validation errors ────────────────────────────────────────────────────
describe("validation", () => {
it("catches unknown field names", async () => {
const wf = new Workflow({ client });
await assert.rejects(
() =>
wf.add({
type: "startCall",
name: "g",
prompt: "hi",
promt: "typo",
}),
(err: unknown) => {
assert.ok(err instanceof ValidationError);
assert.match(err.message, /unknown field/);
return true;
},
);
});
it("catches missing required fields", async () => {
const wf = new Workflow({ client });
await assert.rejects(
() => wf.add({ type: "startCall", name: "g" }),
(err: unknown) => {
assert.ok(err instanceof ValidationError);
assert.match(err.message, /required field missing: prompt/);
return true;
},
);
});
it("catches wrong scalar types", async () => {
const wf = new Workflow({ client });
await assert.rejects(
() =>
wf.add({
type: "agentNode",
name: "x",
prompt: "y",
allow_interrupt: "yes",
}),
(err: unknown) => {
assert.ok(err instanceof ValidationError);
assert.match(err.message, /expected boolean/);
return true;
},
);
});
it("catches invalid options values", async () => {
const wf = new Workflow({ client });
await assert.rejects(
() =>
wf.add({
type: "startCall",
name: "g",
prompt: "hi",
greeting_type: "video",
}),
(err: unknown) => {
assert.ok(err instanceof ValidationError);
assert.match(err.message, /not in allowed/);
return true;
},
);
});
it("surfaces llm_hint in error messages when the spec has one", async () => {
const wf = new Workflow({ client });
await assert.rejects(
() =>
wf.add({
type: "agentNode",
name: "x",
prompt: "y",
tool_uuids: "single-uuid-not-a-list",
}),
(err: unknown) => {
assert.ok(err instanceof ValidationError);
assert.match(err.message, /tool_uuids/);
assert.match(err.message, /Hint:/);
assert.match(err.message, /list_tools/);
return true;
},
);
});
it("does not add 'Hint:' when a spec has no llm_hint", async () => {
const wf = new Workflow({ client });
await assert.rejects(
() =>
wf.add({
type: "agentNode",
name: "x",
prompt: "y",
allow_interrupt: "yes",
}),
(err: unknown) => {
assert.ok(err instanceof ValidationError);
assert.ok(!err.message.includes("Hint:"));
return true;
},
);
});
it("rejects edges without label or condition", async () => {
const wf = new Workflow({ client });
const a = await wf.add({ type: "startCall", name: "a", prompt: "hi" });
const b = await wf.add({ type: "endCall", name: "b", prompt: "bye" });
assert.throws(() => wf.edge(a, b, { label: "", condition: "x" }), ValidationError);
assert.throws(() => wf.edge(a, b, { label: "x", condition: "" }), ValidationError);
});
});
// ─── Round-trip fromJson → edit → toJson ──────────────────────────────────
describe("round-trip", () => {
it("fromJson preserves IDs and subsequent add() does not collide", async () => {
const wf0 = new Workflow({ client });
const start = await wf0.add({ type: "startCall", name: "g", prompt: "hi" });
const end = await wf0.add({ type: "endCall", name: "e", prompt: "bye" });
wf0.edge(start, end, { label: "done", condition: "done" });
const payload = wf0.toJson();
const wf1 = await Workflow.fromJson(payload, { client });
assert.deepEqual(
wf1.toJson().nodes.map((n) => n.id),
[start.id, end.id],
);
const fresh = await wf1.add({
type: "agentNode",
name: "mid",
prompt: "do stuff",
});
assert.notEqual(fresh.id, start.id);
assert.notEqual(fresh.id, end.id);
assert.ok(Number(fresh.id) > Math.max(Number(start.id), Number(end.id)));
});
it("fromJson validates data — unknown field raises", async () => {
const bad = {
nodes: [
{
id: "1",
type: "startCall",
position: { x: 0, y: 0 },
data: { name: "g", prompt: "hi", bogus: 1 },
},
],
edges: [],
};
await assert.rejects(
() => Workflow.fromJson(bad, { client }),
(err: unknown) => {
assert.ok(err instanceof ValidationError);
assert.match(err.message, /unknown field/);
return true;
},
);
});
});
// ─── DograhClient HTTP plumbing (stubbed fetch) ───────────────────────────
describe("DograhClient", () => {
it("sends the API key as X-API-Key", async () => {
let capturedHeaders: Headers | undefined;
const stubFetch: typeof fetch = async (_input, init) => {
capturedHeaders = new Headers(init?.headers);
return new Response(
JSON.stringify({ spec_version: "1.0.0", node_types: [] }),
{ status: 200, headers: { "content-type": "application/json" } },
);
};
const c = new DograhClient({
baseUrl: "http://api.example",
apiKey: "sk-test",
fetch: stubFetch,
});
await c.listNodeTypes();
assert.equal(capturedHeaders?.get("x-api-key"), "sk-test");
});
it("surfaces 4xx responses as ApiError", async () => {
const stubFetch: typeof fetch = async () =>
new Response(JSON.stringify({ detail: "Unknown node type: 'foo'" }), {
status: 404,
headers: { "content-type": "application/json" },
});
const c = new DograhClient({
baseUrl: "http://api.example",
apiKey: "k",
fetch: stubFetch,
});
await assert.rejects(
() => c.getNodeType("foo"),
(err: unknown) => {
assert.ok(err instanceof SpecMismatchError);
return true;
},
);
});
it("caches specs per client so a second get_node_type is free", async () => {
let calls = 0;
const spec: NodeSpec = {
name: "startCall",
display_name: "Start",
description: "d",
category: "call_node",
icon: "Play",
version: "1.0.0",
properties: [],
};
const stubFetch: typeof fetch = async () => {
calls++;
return new Response(JSON.stringify(spec), {
status: 200,
headers: { "content-type": "application/json" },
});
};
const c = new DograhClient({
baseUrl: "http://api.example",
apiKey: "k",
fetch: stubFetch,
});
await c.getNodeType("startCall");
await c.getNodeType("startCall");
assert.equal(calls, 1);
});
it("ApiError constructor stores statusCode and body", () => {
const err = new ApiError(500, "boom", { detail: "oops" });
assert.equal(err.statusCode, 500);
assert.deepEqual(err.body, { detail: "oops" });
});
});

View file

@ -0,0 +1,131 @@
// Tests for the typed SDK (`@dograh/sdk/typed`). Mirrors
// api/tests/test_dograh_sdk_typed.py — checks that generated factories
// produce objects consumable by `workflow.addTyped()`.
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
agentNode,
endCall,
startCall,
type AgentNode,
type EndCall,
type StartCall,
type Trigger,
type TypedNode,
} from "../dist/typed/index.js";
import { Workflow, type NodeSpec } from "../dist/index.js";
import type { SpecProvider } from "../dist/workflow.js";
// Minimal spec stub matching the shape `getNodeType` returns — we just
// need `properties` for the validator to do its job.
const MINIMAL_SPECS: Record<string, NodeSpec> = {
startCall: {
name: "startCall",
display_name: "Start Call",
description: "entry",
category: "call_node",
icon: "Play",
version: "1.0.0",
properties: [
{ name: "name", type: "string", display_name: "N", description: "d", required: true, default: "Start Call" },
{ name: "prompt", type: "mention_textarea", display_name: "P", description: "d", required: true },
],
},
agentNode: {
name: "agentNode",
display_name: "Agent",
description: "step",
category: "call_node",
icon: "Headset",
version: "1.0.0",
properties: [
{ name: "name", type: "string", display_name: "N", description: "d", required: true },
{ name: "prompt", type: "mention_textarea", display_name: "P", description: "d", required: true },
],
},
endCall: {
name: "endCall",
display_name: "End",
description: "terminal",
category: "call_node",
icon: "OctagonX",
version: "1.0.0",
properties: [
{ name: "name", type: "string", display_name: "N", description: "d", required: true },
{ name: "prompt", type: "mention_textarea", display_name: "P", description: "d", required: true },
],
},
};
class StubClient implements SpecProvider {
async getNodeType(name: string): Promise<NodeSpec> {
const s = MINIMAL_SPECS[name];
if (!s) throw new Error(`Unknown spec: ${name}`);
return s;
}
}
// ─── Factories stamp the `type` discriminator ─────────────────────────────
describe("typed factories", () => {
it("startCall() fills in the type discriminator", () => {
const node = startCall({ name: "g", prompt: "hi" });
assert.equal(node.type, "startCall");
assert.equal(node.name, "g");
assert.equal(node.prompt, "hi");
});
it("agentNode() fills in the type discriminator", () => {
const node = agentNode({ name: "a", prompt: "ask" });
assert.equal(node.type, "agentNode");
});
it("endCall() fills in the type discriminator", () => {
const node = endCall({ name: "e", prompt: "bye" });
assert.equal(node.type, "endCall");
});
});
// ─── Workflow.addTyped integrates with the generic builder ────────────────
describe("Workflow.addTyped", () => {
it("accepts a typed factory result and round-trips through toJson", async () => {
const wf = new Workflow({ client: new StubClient(), name: "typed-e2e" });
const start = await wf.addTyped(startCall({ name: "g", prompt: "hi" }));
const end = await wf.addTyped(endCall({ name: "e", prompt: "bye" }));
wf.edge(start, end, { label: "done", condition: "done" });
const payload = wf.toJson();
assert.equal(payload.nodes.length, 2);
assert.equal(payload.nodes[0]!.type, "startCall");
assert.equal(payload.nodes[1]!.type, "endCall");
assert.equal(payload.edges.length, 1);
});
it("addTyped and add produce identical node data for equivalent inputs", async () => {
const typedWf = new Workflow({ client: new StubClient() });
await typedWf.addTyped(agentNode({ name: "q", prompt: "ask" }));
const genericWf = new Workflow({ client: new StubClient() });
await genericWf.add({ type: "agentNode", name: "q", prompt: "ask" });
assert.deepEqual(
typedWf.toJson().nodes[0]!.data,
genericWf.toJson().nodes[0]!.data,
);
});
it("TypedNode union narrows correctly on `type`", async () => {
// Compile-time check — TS narrows on the literal discriminator.
const node: TypedNode = startCall({ name: "g", prompt: "hi" });
if (node.type === "startCall") {
// `node` is narrowed to StartCall here; the following access
// compiles without a cast.
assert.equal(node.prompt, "hi");
} else {
assert.fail("expected StartCall narrowing");
}
});
});

View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"noUncheckedIndexedAccess": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}