mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-10 08:05:22 +02:00
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:
parent
0a61ef295f
commit
00a1a22b74
162 changed files with 14355 additions and 3554 deletions
2
sdk/typescript/.gitignore
vendored
Normal file
2
sdk/typescript/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
dist/
|
||||
*.tsbuildinfo
|
||||
24
sdk/typescript/LICENSE
Normal file
24
sdk/typescript/LICENSE
Normal 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
91
sdk/typescript/README.md
Normal 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
389
sdk/typescript/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
52
sdk/typescript/package.json
Normal file
52
sdk/typescript/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
258
sdk/typescript/scripts/codegen.mts
Normal file
258
sdk/typescript/scripts/codegen.mts
Normal 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);
|
||||
});
|
||||
96
sdk/typescript/src/_generated_client.ts
Normal file
96
sdk/typescript/src/_generated_client.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
1165
sdk/typescript/src/_generated_models.ts
Normal file
1165
sdk/typescript/src/_generated_models.ts
Normal file
File diff suppressed because it is too large
Load diff
175
sdk/typescript/src/client.ts
Normal file
175
sdk/typescript/src/client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
sdk/typescript/src/errors.ts
Normal file
45
sdk/typescript/src/errors.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
59
sdk/typescript/src/index.ts
Normal file
59
sdk/typescript/src/index.ts
Normal 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";
|
||||
77
sdk/typescript/src/typed/agent-node.ts
Normal file
77
sdk/typescript/src/typed/agent-node.ts
Normal 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 };
|
||||
}
|
||||
61
sdk/typescript/src/typed/end-call.ts
Normal file
61
sdk/typescript/src/typed/end-call.ts
Normal 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 };
|
||||
}
|
||||
28
sdk/typescript/src/typed/global-node.ts
Normal file
28
sdk/typescript/src/typed/global-node.ts
Normal 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 };
|
||||
}
|
||||
25
sdk/typescript/src/typed/index.ts
Normal file
25
sdk/typescript/src/typed/index.ts
Normal 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;
|
||||
64
sdk/typescript/src/typed/qa.ts
Normal file
64
sdk/typescript/src/typed/qa.ts
Normal 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 };
|
||||
}
|
||||
113
sdk/typescript/src/typed/start-call.ts
Normal file
113
sdk/typescript/src/typed/start-call.ts
Normal 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.1–10.
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
32
sdk/typescript/src/typed/trigger.ts
Normal file
32
sdk/typescript/src/typed/trigger.ts
Normal 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 };
|
||||
}
|
||||
67
sdk/typescript/src/typed/webhook.ts
Normal file
67
sdk/typescript/src/typed/webhook.ts
Normal 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
101
sdk/typescript/src/types.ts
Normal 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 };
|
||||
}
|
||||
157
sdk/typescript/src/validation.ts
Normal file
157
sdk/typescript/src/validation.ts
Normal 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;
|
||||
}
|
||||
194
sdk/typescript/src/workflow.ts
Normal file
194
sdk/typescript/src/workflow.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
423
sdk/typescript/tests/sdk.test.mts
Normal file
423
sdk/typescript/tests/sdk.test.mts
Normal 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" });
|
||||
});
|
||||
});
|
||||
131
sdk/typescript/tests/typed.test.mts
Normal file
131
sdk/typescript/tests/typed.test.mts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
20
sdk/typescript/tsconfig.json
Normal file
20
sdk/typescript/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue