@guidify-ai/vapi-studio 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/agent/AGENTS.md +42 -0
- package/agent/CLAUDE.md +15 -0
- package/agent/claude/rules/ui-and-api-identity.md +9 -0
- package/agent/claude/rules/vapi-studio-best-practices.md +36 -0
- package/agent/cursor/ui-and-api-identity.mdc +16 -0
- package/agent/cursor/vapi-studio-best-practices.mdc +43 -0
- package/dist/adapters/vapi/vapi-sse.compiler.d.ts +61 -0
- package/dist/adapters/vapi/vapi-sse.compiler.d.ts.map +1 -0
- package/dist/adapters/vapi/vapi-sse.compiler.js +396 -0
- package/dist/adapters/vapi/vapi-sse.compiler.js.map +1 -0
- package/dist/analytics/analytics-tags.d.ts +60 -0
- package/dist/analytics/analytics-tags.d.ts.map +1 -0
- package/dist/analytics/analytics-tags.js +27 -0
- package/dist/analytics/analytics-tags.js.map +1 -0
- package/dist/brain/adapters/chatgpt-brain.adapter.d.ts +27 -0
- package/dist/brain/adapters/chatgpt-brain.adapter.d.ts.map +1 -0
- package/dist/brain/adapters/chatgpt-brain.adapter.js +94 -0
- package/dist/brain/adapters/chatgpt-brain.adapter.js.map +1 -0
- package/dist/brain/adapters/claude-brain.adapter.d.ts +24 -0
- package/dist/brain/adapters/claude-brain.adapter.d.ts.map +1 -0
- package/dist/brain/adapters/claude-brain.adapter.js +96 -0
- package/dist/brain/adapters/claude-brain.adapter.js.map +1 -0
- package/dist/brain/adapters/gemini-brain.adapter.d.ts +24 -0
- package/dist/brain/adapters/gemini-brain.adapter.d.ts.map +1 -0
- package/dist/brain/adapters/gemini-brain.adapter.js +106 -0
- package/dist/brain/adapters/gemini-brain.adapter.js.map +1 -0
- package/dist/brain/adapters/grok-brain.adapter.d.ts +24 -0
- package/dist/brain/adapters/grok-brain.adapter.d.ts.map +1 -0
- package/dist/brain/adapters/grok-brain.adapter.js +90 -0
- package/dist/brain/adapters/grok-brain.adapter.js.map +1 -0
- package/dist/brain/adapters/json-llm-brain.base.d.ts +63 -0
- package/dist/brain/adapters/json-llm-brain.base.d.ts.map +1 -0
- package/dist/brain/adapters/json-llm-brain.base.js +511 -0
- package/dist/brain/adapters/json-llm-brain.base.js.map +1 -0
- package/dist/brain/adapters/mock-brain.adapter.d.ts +32 -0
- package/dist/brain/adapters/mock-brain.adapter.d.ts.map +1 -0
- package/dist/brain/adapters/mock-brain.adapter.js +201 -0
- package/dist/brain/adapters/mock-brain.adapter.js.map +1 -0
- package/dist/brain/adapters/mock-provider-brains.adapter.d.ts +22 -0
- package/dist/brain/adapters/mock-provider-brains.adapter.d.ts.map +1 -0
- package/dist/brain/adapters/mock-provider-brains.adapter.js +48 -0
- package/dist/brain/adapters/mock-provider-brains.adapter.js.map +1 -0
- package/dist/brain/brain-clarify.d.ts +75 -0
- package/dist/brain/brain-clarify.d.ts.map +1 -0
- package/dist/brain/brain-clarify.js +146 -0
- package/dist/brain/brain-clarify.js.map +1 -0
- package/dist/brain/brain-config.d.ts +20 -0
- package/dist/brain/brain-config.d.ts.map +1 -0
- package/dist/brain/brain-config.js +20 -0
- package/dist/brain/brain-config.js.map +1 -0
- package/dist/brain/brain-judge.d.ts +68 -0
- package/dist/brain/brain-judge.d.ts.map +1 -0
- package/dist/brain/brain-judge.js +111 -0
- package/dist/brain/brain-judge.js.map +1 -0
- package/dist/brain/brain-ranking.d.ts +41 -0
- package/dist/brain/brain-ranking.d.ts.map +1 -0
- package/dist/brain/brain-ranking.js +103 -0
- package/dist/brain/brain-ranking.js.map +1 -0
- package/dist/brain/brain-usage.tracker.d.ts +48 -0
- package/dist/brain/brain-usage.tracker.d.ts.map +1 -0
- package/dist/brain/brain-usage.tracker.js +106 -0
- package/dist/brain/brain-usage.tracker.js.map +1 -0
- package/dist/brain/brain.port.d.ts +63 -0
- package/dist/brain/brain.port.d.ts.map +1 -0
- package/dist/brain/brain.port.js +7 -0
- package/dist/brain/brain.port.js.map +1 -0
- package/dist/brain/brain.service.d.ts +14 -0
- package/dist/brain/brain.service.d.ts.map +1 -0
- package/dist/brain/brain.service.js +27 -0
- package/dist/brain/brain.service.js.map +1 -0
- package/dist/brain/claude-cheap-models.d.ts +18 -0
- package/dist/brain/claude-cheap-models.d.ts.map +1 -0
- package/dist/brain/claude-cheap-models.js +53 -0
- package/dist/brain/claude-cheap-models.js.map +1 -0
- package/dist/brain/gemini-cheap-models.d.ts +18 -0
- package/dist/brain/gemini-cheap-models.d.ts.map +1 -0
- package/dist/brain/gemini-cheap-models.js +53 -0
- package/dist/brain/gemini-cheap-models.js.map +1 -0
- package/dist/brain/grok-cheap-models.d.ts +18 -0
- package/dist/brain/grok-cheap-models.d.ts.map +1 -0
- package/dist/brain/grok-cheap-models.js +53 -0
- package/dist/brain/grok-cheap-models.js.map +1 -0
- package/dist/brain/llm-model-pricing.d.ts +29 -0
- package/dist/brain/llm-model-pricing.d.ts.map +1 -0
- package/dist/brain/llm-model-pricing.js +38 -0
- package/dist/brain/llm-model-pricing.js.map +1 -0
- package/dist/brain/openai-cheap-models.d.ts +34 -0
- package/dist/brain/openai-cheap-models.d.ts.map +1 -0
- package/dist/brain/openai-cheap-models.js +72 -0
- package/dist/brain/openai-cheap-models.js.map +1 -0
- package/dist/brain/prompt-injection-guard.d.ts +34 -0
- package/dist/brain/prompt-injection-guard.d.ts.map +1 -0
- package/dist/brain/prompt-injection-guard.js +109 -0
- package/dist/brain/prompt-injection-guard.js.map +1 -0
- package/dist/channel/channel-tools.d.ts +72 -0
- package/dist/channel/channel-tools.d.ts.map +1 -0
- package/dist/channel/channel-tools.js +131 -0
- package/dist/channel/channel-tools.js.map +1 -0
- package/dist/conversation/call-turn-queue.d.ts +64 -0
- package/dist/conversation/call-turn-queue.d.ts.map +1 -0
- package/dist/conversation/call-turn-queue.js +156 -0
- package/dist/conversation/call-turn-queue.js.map +1 -0
- package/dist/conversation/conversation-bootstrap.service.d.ts +60 -0
- package/dist/conversation/conversation-bootstrap.service.d.ts.map +1 -0
- package/dist/conversation/conversation-bootstrap.service.js +383 -0
- package/dist/conversation/conversation-bootstrap.service.js.map +1 -0
- package/dist/conversation/conversation-entry.d.ts +38 -0
- package/dist/conversation/conversation-entry.d.ts.map +1 -0
- package/dist/conversation/conversation-entry.js +17 -0
- package/dist/conversation/conversation-entry.js.map +1 -0
- package/dist/conversation/conversation-history.d.ts +31 -0
- package/dist/conversation/conversation-history.d.ts.map +1 -0
- package/dist/conversation/conversation-history.js +53 -0
- package/dist/conversation/conversation-history.js.map +1 -0
- package/dist/conversation/conversation-limits.d.ts +49 -0
- package/dist/conversation/conversation-limits.d.ts.map +1 -0
- package/dist/conversation/conversation-limits.js +44 -0
- package/dist/conversation/conversation-limits.js.map +1 -0
- package/dist/conversation/conversation-resume.service.d.ts +39 -0
- package/dist/conversation/conversation-resume.service.d.ts.map +1 -0
- package/dist/conversation/conversation-resume.service.js +234 -0
- package/dist/conversation/conversation-resume.service.js.map +1 -0
- package/dist/conversation/conversation-schema.d.ts +23 -0
- package/dist/conversation/conversation-schema.d.ts.map +1 -0
- package/dist/conversation/conversation-schema.js +3 -0
- package/dist/conversation/conversation-schema.js.map +1 -0
- package/dist/conversation/listen-expectation.d.ts +145 -0
- package/dist/conversation/listen-expectation.d.ts.map +1 -0
- package/dist/conversation/listen-expectation.js +219 -0
- package/dist/conversation/listen-expectation.js.map +1 -0
- package/dist/conversation/listen-timeout.d.ts +42 -0
- package/dist/conversation/listen-timeout.d.ts.map +1 -0
- package/dist/conversation/listen-timeout.js +57 -0
- package/dist/conversation/listen-timeout.js.map +1 -0
- package/dist/conversation/supervised-conversation.d.ts +64 -0
- package/dist/conversation/supervised-conversation.d.ts.map +1 -0
- package/dist/conversation/supervised-conversation.js +198 -0
- package/dist/conversation/supervised-conversation.js.map +1 -0
- package/dist/conversation/supervised-conversation.registry.d.ts +14 -0
- package/dist/conversation/supervised-conversation.registry.d.ts.map +1 -0
- package/dist/conversation/supervised-conversation.registry.js +47 -0
- package/dist/conversation/supervised-conversation.registry.js.map +1 -0
- package/dist/conversation/types.d.ts +52 -0
- package/dist/conversation/types.d.ts.map +1 -0
- package/dist/conversation/types.js +3 -0
- package/dist/conversation/types.js.map +1 -0
- package/dist/events/conversation-console.d.ts +17 -0
- package/dist/events/conversation-console.d.ts.map +1 -0
- package/dist/events/conversation-console.js +863 -0
- package/dist/events/conversation-console.js.map +1 -0
- package/dist/events/daily-log.driver.d.ts +35 -0
- package/dist/events/daily-log.driver.d.ts.map +1 -0
- package/dist/events/daily-log.driver.js +206 -0
- package/dist/events/daily-log.driver.js.map +1 -0
- package/dist/events/event.service.d.ts +24 -0
- package/dist/events/event.service.d.ts.map +1 -0
- package/dist/events/event.service.js +141 -0
- package/dist/events/event.service.js.map +1 -0
- package/dist/events/route-forensics.d.ts +38 -0
- package/dist/events/route-forensics.d.ts.map +1 -0
- package/dist/events/route-forensics.js +85 -0
- package/dist/events/route-forensics.js.map +1 -0
- package/dist/events/studio-event-bus.d.ts +17 -0
- package/dist/events/studio-event-bus.d.ts.map +1 -0
- package/dist/events/studio-event-bus.js +37 -0
- package/dist/events/studio-event-bus.js.map +1 -0
- package/dist/events/studio-event.d.ts +44 -0
- package/dist/events/studio-event.d.ts.map +1 -0
- package/dist/events/studio-event.js +27 -0
- package/dist/events/studio-event.js.map +1 -0
- package/dist/flow/condition.d.ts +35 -0
- package/dist/flow/condition.d.ts.map +1 -0
- package/dist/flow/condition.js +246 -0
- package/dist/flow/condition.js.map +1 -0
- package/dist/flow/flow-loader.d.ts +71 -0
- package/dist/flow/flow-loader.d.ts.map +1 -0
- package/dist/flow/flow-loader.js +182 -0
- package/dist/flow/flow-loader.js.map +1 -0
- package/dist/forms/form-html.d.ts +30 -0
- package/dist/forms/form-html.d.ts.map +1 -0
- package/dist/forms/form-html.js +130 -0
- package/dist/forms/form-html.js.map +1 -0
- package/dist/forms/form.tokens.d.ts +3 -0
- package/dist/forms/form.tokens.d.ts.map +1 -0
- package/dist/forms/form.tokens.js +6 -0
- package/dist/forms/form.tokens.js.map +1 -0
- package/dist/forms/form.types.d.ts +81 -0
- package/dist/forms/form.types.d.ts.map +1 -0
- package/dist/forms/form.types.js +36 -0
- package/dist/forms/form.types.js.map +1 -0
- package/dist/forms/forms.service.d.ts +52 -0
- package/dist/forms/forms.service.d.ts.map +1 -0
- package/dist/forms/forms.service.js +434 -0
- package/dist/forms/forms.service.js.map +1 -0
- package/dist/forms/noop-form-dispose.adapter.d.ts +12 -0
- package/dist/forms/noop-form-dispose.adapter.d.ts.map +1 -0
- package/dist/forms/noop-form-dispose.adapter.js +27 -0
- package/dist/forms/noop-form-dispose.adapter.js.map +1 -0
- package/dist/forms/twilio-sms-form-dispose.adapter.d.ts +33 -0
- package/dist/forms/twilio-sms-form-dispose.adapter.d.ts.map +1 -0
- package/dist/forms/twilio-sms-form-dispose.adapter.js +125 -0
- package/dist/forms/twilio-sms-form-dispose.adapter.js.map +1 -0
- package/dist/forms/twilio-sms.env.d.ts +62 -0
- package/dist/forms/twilio-sms.env.d.ts.map +1 -0
- package/dist/forms/twilio-sms.env.js +147 -0
- package/dist/forms/twilio-sms.env.js.map +1 -0
- package/dist/forms/twilio-sms.sender.d.ts +27 -0
- package/dist/forms/twilio-sms.sender.d.ts.map +1 -0
- package/dist/forms/twilio-sms.sender.js +75 -0
- package/dist/forms/twilio-sms.sender.js.map +1 -0
- package/dist/identity/email-parse.d.ts +20 -0
- package/dist/identity/email-parse.d.ts.map +1 -0
- package/dist/identity/email-parse.js +102 -0
- package/dist/identity/email-parse.js.map +1 -0
- package/dist/identity/index.d.ts +17 -0
- package/dist/identity/index.d.ts.map +1 -0
- package/dist/identity/index.js +23 -0
- package/dist/identity/index.js.map +1 -0
- package/dist/index.d.ts +108 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +300 -0
- package/dist/index.js.map +1 -0
- package/dist/integrations/integration-client.d.ts +41 -0
- package/dist/integrations/integration-client.d.ts.map +1 -0
- package/dist/integrations/integration-client.js +152 -0
- package/dist/integrations/integration-client.js.map +1 -0
- package/dist/integrations/jwt-hmac.d.ts +33 -0
- package/dist/integrations/jwt-hmac.d.ts.map +1 -0
- package/dist/integrations/jwt-hmac.js +108 -0
- package/dist/integrations/jwt-hmac.js.map +1 -0
- package/dist/intention/code-intention.d.ts +94 -0
- package/dist/intention/code-intention.d.ts.map +1 -0
- package/dist/intention/code-intention.js +89 -0
- package/dist/intention/code-intention.js.map +1 -0
- package/dist/intention/intention-constants.d.ts +37 -0
- package/dist/intention/intention-constants.d.ts.map +1 -0
- package/dist/intention/intention-constants.js +37 -0
- package/dist/intention/intention-constants.js.map +1 -0
- package/dist/intentions/standard-intentions.d.ts +26 -0
- package/dist/intentions/standard-intentions.d.ts.map +1 -0
- package/dist/intentions/standard-intentions.js +28 -0
- package/dist/intentions/standard-intentions.js.map +1 -0
- package/dist/node/agent-node.d.ts +156 -0
- package/dist/node/agent-node.d.ts.map +1 -0
- package/dist/node/agent-node.js +179 -0
- package/dist/node/agent-node.js.map +1 -0
- package/dist/node/catch-directive.d.ts +33 -0
- package/dist/node/catch-directive.d.ts.map +1 -0
- package/dist/node/catch-directive.js +41 -0
- package/dist/node/catch-directive.js.map +1 -0
- package/dist/output/conversation-output.d.ts +113 -0
- package/dist/output/conversation-output.d.ts.map +1 -0
- package/dist/output/conversation-output.js +100 -0
- package/dist/output/conversation-output.js.map +1 -0
- package/dist/persistence/conversation.entity.d.ts +27 -0
- package/dist/persistence/conversation.entity.d.ts.map +1 -0
- package/dist/persistence/conversation.entity.js +115 -0
- package/dist/persistence/conversation.entity.js.map +1 -0
- package/dist/persistence/conversation.repository.d.ts +82 -0
- package/dist/persistence/conversation.repository.d.ts.map +1 -0
- package/dist/persistence/conversation.repository.js +277 -0
- package/dist/persistence/conversation.repository.js.map +1 -0
- package/dist/persistence/project.entity.d.ts +12 -0
- package/dist/persistence/project.entity.d.ts.map +1 -0
- package/dist/persistence/project.entity.js +46 -0
- package/dist/persistence/project.entity.js.map +1 -0
- package/dist/persistence/project.repository.d.ts +17 -0
- package/dist/persistence/project.repository.d.ts.map +1 -0
- package/dist/persistence/project.repository.js +69 -0
- package/dist/persistence/project.repository.js.map +1 -0
- package/dist/persistence/provider-ingress.entity.d.ts +18 -0
- package/dist/persistence/provider-ingress.entity.d.ts.map +1 -0
- package/dist/persistence/provider-ingress.entity.js +91 -0
- package/dist/persistence/provider-ingress.entity.js.map +1 -0
- package/dist/persistence/provider-ingress.repository.d.ts +21 -0
- package/dist/persistence/provider-ingress.repository.d.ts.map +1 -0
- package/dist/persistence/provider-ingress.repository.js +76 -0
- package/dist/persistence/provider-ingress.repository.js.map +1 -0
- package/dist/studio-ui/assets/index-BGd2OhLv.js +84 -0
- package/dist/studio-ui/assets/index-BGd2OhLv.js.map +1 -0
- package/dist/studio-ui/assets/index-DnJos5x2.css +1 -0
- package/dist/studio-ui/index.html +13 -0
- package/dist/studio-ui-mount/index.d.ts +6 -0
- package/dist/studio-ui-mount/index.d.ts.map +1 -0
- package/dist/studio-ui-mount/index.js +13 -0
- package/dist/studio-ui-mount/index.js.map +1 -0
- package/dist/studio-ui-mount/resolve-studio-ui-root.d.ts +6 -0
- package/dist/studio-ui-mount/resolve-studio-ui-root.d.ts.map +1 -0
- package/dist/studio-ui-mount/resolve-studio-ui-root.js +25 -0
- package/dist/studio-ui-mount/resolve-studio-ui-root.js.map +1 -0
- package/dist/studio-ui-mount/studio-spa.controller.d.ts +18 -0
- package/dist/studio-ui-mount/studio-spa.controller.d.ts.map +1 -0
- package/dist/studio-ui-mount/studio-spa.controller.js +57 -0
- package/dist/studio-ui-mount/studio-spa.controller.js.map +1 -0
- package/dist/studio-ui-mount/studio-ui.module.d.ts +15 -0
- package/dist/studio-ui-mount/studio-ui.module.d.ts.map +1 -0
- package/dist/studio-ui-mount/studio-ui.module.js +47 -0
- package/dist/studio-ui-mount/studio-ui.module.js.map +1 -0
- package/dist/studio-ui-mount/studio-ui.tokens.d.ts +2 -0
- package/dist/studio-ui-mount/studio-ui.tokens.d.ts.map +1 -0
- package/dist/studio-ui-mount/studio-ui.tokens.js +5 -0
- package/dist/studio-ui-mount/studio-ui.tokens.js.map +1 -0
- package/dist/supervisor/supervisor-cascade.d.ts +55 -0
- package/dist/supervisor/supervisor-cascade.d.ts.map +1 -0
- package/dist/supervisor/supervisor-cascade.js +261 -0
- package/dist/supervisor/supervisor-cascade.js.map +1 -0
- package/dist/supervisor/supervisor-engine.d.ts +9 -0
- package/dist/supervisor/supervisor-engine.d.ts.map +1 -0
- package/dist/supervisor/supervisor-engine.js +64 -0
- package/dist/supervisor/supervisor-engine.js.map +1 -0
- package/dist/supervisor/supervisor-limits.d.ts +8 -0
- package/dist/supervisor/supervisor-limits.d.ts.map +1 -0
- package/dist/supervisor/supervisor-limits.js +36 -0
- package/dist/supervisor/supervisor-limits.js.map +1 -0
- package/dist/supervisor/supervisor-node-exec.d.ts +39 -0
- package/dist/supervisor/supervisor-node-exec.d.ts.map +1 -0
- package/dist/supervisor/supervisor-node-exec.js +201 -0
- package/dist/supervisor/supervisor-node-exec.js.map +1 -0
- package/dist/supervisor/supervisor-orchestration.d.ts +24 -0
- package/dist/supervisor/supervisor-orchestration.d.ts.map +1 -0
- package/dist/supervisor/supervisor-orchestration.js +395 -0
- package/dist/supervisor/supervisor-orchestration.js.map +1 -0
- package/dist/supervisor/supervisor-portal.d.ts +75 -0
- package/dist/supervisor/supervisor-portal.d.ts.map +1 -0
- package/dist/supervisor/supervisor-portal.js +320 -0
- package/dist/supervisor/supervisor-portal.js.map +1 -0
- package/dist/supervisor/supervisor-route.d.ts +53 -0
- package/dist/supervisor/supervisor-route.d.ts.map +1 -0
- package/dist/supervisor/supervisor-route.js +308 -0
- package/dist/supervisor/supervisor-route.js.map +1 -0
- package/dist/supervisor/supervisor-special-turns.d.ts +34 -0
- package/dist/supervisor/supervisor-special-turns.d.ts.map +1 -0
- package/dist/supervisor/supervisor-special-turns.js +308 -0
- package/dist/supervisor/supervisor-special-turns.js.map +1 -0
- package/dist/supervisor/supervisor.d.ts +44 -0
- package/dist/supervisor/supervisor.d.ts.map +1 -0
- package/dist/supervisor/supervisor.js +79 -0
- package/dist/supervisor/supervisor.js.map +1 -0
- package/dist/supervisor/supervisor.types.d.ts +71 -0
- package/dist/supervisor/supervisor.types.d.ts.map +1 -0
- package/dist/supervisor/supervisor.types.js +8 -0
- package/dist/supervisor/supervisor.types.js.map +1 -0
- package/dist/tasks/studio-task-queue.d.ts +93 -0
- package/dist/tasks/studio-task-queue.d.ts.map +1 -0
- package/dist/tasks/studio-task-queue.js +226 -0
- package/dist/tasks/studio-task-queue.js.map +1 -0
- package/dist/util/studio-env.d.ts +7 -0
- package/dist/util/studio-env.d.ts.map +1 -0
- package/dist/util/studio-env.js +32 -0
- package/dist/util/studio-env.js.map +1 -0
- package/dist/vapi-studio.module.d.ts +55 -0
- package/dist/vapi-studio.module.d.ts.map +1 -0
- package/dist/vapi-studio.module.js +166 -0
- package/dist/vapi-studio.module.js.map +1 -0
- package/dist/workflow/workflow-handoff.service.d.ts +29 -0
- package/dist/workflow/workflow-handoff.service.d.ts.map +1 -0
- package/dist/workflow/workflow-handoff.service.js +165 -0
- package/dist/workflow/workflow-handoff.service.js.map +1 -0
- package/dist/workflow/workflow-loader.d.ts +48 -0
- package/dist/workflow/workflow-loader.d.ts.map +1 -0
- package/dist/workflow/workflow-loader.js +87 -0
- package/dist/workflow/workflow-loader.js.map +1 -0
- package/docs/README.md +69 -0
- package/docs/assets/deterministic-assistant-flow.svg +128 -0
- package/docs/assets/vapi-studio-stack.svg +26 -0
- package/docs/best-practices/README.md +37 -0
- package/docs/best-practices/brain-and-prompt-injection.md +70 -0
- package/docs/best-practices/conversation-design.md +152 -0
- package/docs/best-practices/debugging-and-observability.md +49 -0
- package/docs/best-practices/documentation-layers.md +31 -0
- package/docs/best-practices/identity-and-pii.md +66 -0
- package/docs/best-practices/nodes-and-listens.md +116 -0
- package/docs/best-practices/ui-and-api-identity.md +51 -0
- package/docs/building-apps/creating-an-app.md +59 -0
- package/docs/building-apps/example-apps.md +23 -0
- package/docs/building-apps/overview.md +27 -0
- package/docs/getting-started/installation.md +52 -0
- package/docs/getting-started/introduction.md +23 -0
- package/docs/getting-started/quick-start.md +23 -0
- package/docs/guide/concepts.md +103 -0
- package/docs/guide/forms.md +102 -0
- package/docs/guide/handbook.md +21 -0
- package/docs/guide/introduction.md +56 -0
- package/docs/guide/module-setup.md +68 -0
- package/docs/guide/vapi-adapter.md +70 -0
- package/docs/guide/workflow-squad.md +59 -0
- package/docs/guides/agents.md +85 -0
- package/docs/guides/docker-compose.events.stub.yaml +27 -0
- package/docs/guides/documentation.md +13 -0
- package/docs/guides/extending-events.md +155 -0
- package/docs/guides/node-conventions.md +59 -0
- package/docs/guides/speckit.md +56 -0
- package/docs/reference/data-model.md +79 -0
- package/docs/reference/environment-variables.md +60 -0
- package/docs/reference/events-and-logging.md +137 -0
- package/docs/reference/flow-schema.md +79 -0
- package/docs/reference/runtime-api.md +708 -0
- package/docs/reference/security.md +41 -0
- package/docs/reference/standard-intentions.md +49 -0
- package/docs/reference/testing.md +15 -0
- package/docs/samples/events/nest-listener.example.ts +17 -0
- package/docs/samples/events/redis-bridge.example.ts +44 -0
- package/package.json +81 -0
- package/scripts/fix-access-modifiers.mjs +85 -0
- package/scripts/install-agent-refs.cjs +196 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
# Runtime API reference
|
|
2
|
+
|
|
3
|
+
Shipped behavior and contracts for `@guidify-ai/vapi-studio`. **Update this file in the same change as any framework code change.**
|
|
4
|
+
|
|
5
|
+
The repository [README](../../README.md) is the entry index — not a usage guide. Start there, then use [docs/README.md](../README.md) for navigation.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
**Deterministic voice agents** for Vapi — NestJS orchestration with typed agent steps, explicit `flow.yaml` paths, and optional Brain interpretation at listen boundaries.
|
|
12
|
+
|
|
13
|
+
Vapi Studio is not a no-code builder and not an open-ended LLM agent. Behavior is **code + YAML routing**; the Brain assists where interpretation is needed, not where the graph is already decided.
|
|
14
|
+
|
|
15
|
+
- **Repository:** [guidify-ai/vapi-studio](https://github.com/guidify-ai/vapi-studio)
|
|
16
|
+
- **Entry index:** [README.md](../../README.md)
|
|
17
|
+
- **Documentation site:** [`docs/README.md`](../README.md)
|
|
18
|
+
- **Best practices:** [`docs/best-practices/`](../best-practices/) · [`agent/AGENTS.md`](../../agent/AGENTS.md)
|
|
19
|
+
- **Example applications:** [`docs/building-apps/example-apps.md`](../building-apps/example-apps.md)
|
|
20
|
+
|
|
21
|
+
Application-specific agent steps, copy, and flows do **not** belong in this repository.
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@guidify-ai/vapi-studio": "0.1.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Build the package (`yarn build` → `dist/`) before the app type-checks against it. App runtime is Docker-only in the current PoC; Yarn in Docker is the supported install path.
|
|
32
|
+
|
|
33
|
+
**Agent refs on install:** `postinstall` runs `scripts/install-agent-refs.cjs`, which writes `.cursor/rules/vapi-studio-best-practices.mdc` and a stamped block in the app’s `AGENTS.md` pointing at `docs/best-practices/`. Re-run with `yarn install-agent-refs` from the package (with `INIT_CWD` set to the app) or `yarn install` in the app.
|
|
34
|
+
|
|
35
|
+
Wire Nest:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
VapiStudioModule.forRoot({
|
|
39
|
+
nodes: [{ className: 'GoodbyeNode', useClass: GoodbyeNode }],
|
|
40
|
+
entryPoint: MyConversationEntry,
|
|
41
|
+
brainAdapter: MockBrainAdapter, // or ChatGpt / Claude / Gemini / Grok / app class
|
|
42
|
+
brain: {
|
|
43
|
+
model: 'gpt-4.1-nano', // adapter cheap whitelist; not .env
|
|
44
|
+
confidenceThreshold: 0.4,
|
|
45
|
+
},
|
|
46
|
+
limits: {
|
|
47
|
+
maxTurns: 40, // default 40; hard ceiling 150
|
|
48
|
+
maxDurationMs: 20 * 60 * 1000, // default 20m; hard ceiling 60m
|
|
49
|
+
},
|
|
50
|
+
eventListeners: [],
|
|
51
|
+
})
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Conversation **limits are always on** (defaults apply when `limits` is omitted). They cannot be disabled — only raised within hard ceilings.
|
|
55
|
+
## Architecture
|
|
56
|
+
|
|
57
|
+
| Piece | Role |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| **Conversation** | One durable identity per provider call (`providerCallId`) + one in-memory `SupervisedConversation` for the live call |
|
|
60
|
+
| **Supervisor** | Each user turn: condition forces → listen resolve / Brain → walk candidates → `before()` → `listen()` → `run()` → `after()` |
|
|
61
|
+
| **Node** | Application class. Owns speech, memory writes, integrations, terminal action |
|
|
62
|
+
| **Flow YAML** | Paths: start node, class names, intentions, portals, priority, optional **condition transitions** |
|
|
63
|
+
| **Brain** | `scan` / `clarify` / `judge`. Does not own routing |
|
|
64
|
+
| **Adapter** | Channel wire format (Vapi Custom LLM SSE, call id, tools). Nodes never see it |
|
|
65
|
+
| **Events** | Event-driven bus: `EventService.emit` → console + `onStudioEvent` + Nest `eventListeners`. Custom listeners welcome; no broker required in OSS |
|
|
66
|
+
| **Integrations** | Signed outbound HTTP from Nodes via `ctx.integrations.request` |
|
|
67
|
+
|
|
68
|
+
Happy-path live call (MVP): bootstrap → in-memory runtime reused across Custom LLM turns → checkpoint to Postgres after each turn → `status-update: ended` finalize. Checkpoints are for **forensics and durability**, not automatic session restore in the MVP.
|
|
69
|
+
|
|
70
|
+
**Post-MVP (framework only, opt-in):** `ConversationBootstrapService.restoreAllActive()` / `simulateCrash()` / `disasterStatus()` rebuild in-memory runtimes after process restart. Apps must not call these on boot unless you explicitly ship crash recovery.
|
|
71
|
+
|
|
72
|
+
**Post-MVP (apps opt-in):** `ConversationResumeService` — same `caller_id` within ~10 minutes may clone a prior conversation into a new call after the caller confirms. Not part of the default MVP happy path.
|
|
73
|
+
|
|
74
|
+
## Conversation lifecycle
|
|
75
|
+
|
|
76
|
+
1. Channel bootstrap (`assistant-request`, `assistant.started`, lazy Custom LLM, or early `status-update`) calls `ConversationBootstrapService.bootstrap({ projectId, providerCallId, brainProfileId, metadata })`.
|
|
77
|
+
2. Postgres row `conversations` (ACTIVE) + in-memory `SupervisedConversation` with a `runtimeInstanceId`. Concurrent bootstrap for the same call is serialized and reused. `caller_id` is stamped from metadata/variables and refreshed on every checkpoint.
|
|
78
|
+
3. **Start (inject / near-zero prep):** optional `ConversationEntryPoint.createVariables` + `beforeEach` seed variables/memory from channel metadata (LP company/email/name, caller phone, feature flags; optional JWT/API warm). This must stay near-zero blocking — it is **not** a spoken flow hop and must not delay first speech. Then an initial `runtime_state` checkpoint. If a workflow is loaded, set `workflowId` / `activeModuleId` and load the entry module’s flow.
|
|
79
|
+
4. **Greeting:** opening Supervisor turn runs **`flow.start` only** (no Brain scan) — first real speak. Context intention is always `studio.opening` (not `flow.nodes[start].intentions[0]`, which is for *routing into* that node later). Each later Custom LLM / studio turn: correlate call id → same runtime → ensure active module flow → `Supervisor.handleTurn` → **checkpoint** (`conversations.runtime_state`, bumps `last_activity_at`). `output.continueTo` jumps within one flow (`FLOW_CONTINUE`); `output.handoff` switches workflow module on the same Conversation (`WORKFLOW_HANDOFF`). Neither finalizes.
|
|
80
|
+
5. `status-update: ended` / studio hangup / farewell `endCall` → `afterEach` → persist `final_state` → drop registry + turn queue.
|
|
81
|
+
|
|
82
|
+
Identity is the **provider call id**. One supervised runtime per active call.
|
|
83
|
+
|
|
84
|
+
## Conversation limits (hard rule)
|
|
85
|
+
|
|
86
|
+
Every conversation has finite bounds. At the start of each `Supervisor` turn (after `turnNumber` bumps), if either cap is exceeded the runtime **skips Brain/routing**, speaks a short goodbye, and returns `endCall`:
|
|
87
|
+
|
|
88
|
+
| Cap | Default | Hard ceiling |
|
|
89
|
+
| --- | --- | --- |
|
|
90
|
+
| `limits.maxTurns` | 40 | 150 |
|
|
91
|
+
| `limits.maxDurationMs` | 20 minutes | 60 minutes |
|
|
92
|
+
|
|
93
|
+
- Turn count = `runtime.turn.turnNumber` (opening, user speech, tool-result, force-intention, and module entry-speak each count).
|
|
94
|
+
- Duration = wall clock from `SupervisedConversation.createdAt` (restored from checkpoint so crash recovery keeps the original start).
|
|
95
|
+
- Forensic event: `CONVERSATION_LIMIT_EXCEEDED` (`reason`: `max_turns` \| `max_duration`).
|
|
96
|
+
- Optional `limits.endMessage` overrides the spoken line (still one CTA / farewell).
|
|
97
|
+
|
|
98
|
+
Apps may raise caps within the ceilings; they must not ship unlimited conversations. Complement with Vapi’s own max-duration / idle policies — Studio only checks at turn boundaries.
|
|
99
|
+
|
|
100
|
+
## Supervisor routing
|
|
101
|
+
|
|
102
|
+
Turn routing is a **cascade** (first hit wins; later steps are skipped). Implementation is split under `src/supervisor/` (`supervisor.ts` Nest facade + orchestration / cascade / route / portal / node-exec / special-turns / limits modules).
|
|
103
|
+
|
|
104
|
+
1. **Code intentions `INTENTION_CASCADE_PHASE.Force`** — `CodeIntention.before()` / `match()` / `run()` with full memory/history/regex freedom. No listen resolve, no Brain. Forensic: `INTENTION_FORCE` + `ROUTE_RESOLVED_VIA.IntentionForce`. Console **IFORCE**.
|
|
105
|
+
2. **YAML condition `force: true` transitions** — declarative sugar over memory/variables. Forensic: `CONDITION_TRANSITION`. Console **FORCE**.
|
|
106
|
+
3. **Code intentions `INTENTION_CASCADE_PHASE.Match`** — local `match()` confidence; may `goto` or inject a score and skip Brain (`ROUTE_RESOLVED_VIA.IntentionMatch` / **IMATCH**).
|
|
107
|
+
4. **Listen `resolveIntention`** — cheap local match → Brain skipped (`ROUTE_RESOLVED_VIA.ListenResolve`).
|
|
108
|
+
5. **Brain `INTENTION_CASCADE_PHASE.Scan`** — scores candidates; soft YAML conditions + registered intention `boost`/`priority` feed the walk.
|
|
109
|
+
6. **Walk** — Node `before()` → enter → `listen` / `run` / `after`.
|
|
110
|
+
|
|
111
|
+
## Intentions (code)
|
|
112
|
+
|
|
113
|
+
Intentions are classes — same spirit as Nodes. Register on `VapiStudioModule.forRoot({ intentions: [...] })`.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import {
|
|
117
|
+
CodeIntention,
|
|
118
|
+
DEFAULT_FORCE_INTENTION_PRIORITY,
|
|
119
|
+
INTENTION_CASCADE_PHASE,
|
|
120
|
+
INTENTION_RUN_KIND,
|
|
121
|
+
} from '@guidify-ai/vapi-studio';
|
|
122
|
+
|
|
123
|
+
/** App-owned ids — colocate with `flow.yaml` and node classes (`as const`, not raw strings). */
|
|
124
|
+
const APP_INTENTIONS = {
|
|
125
|
+
needSmsPhone: 'isNeedSmsPhone',
|
|
126
|
+
} as const;
|
|
127
|
+
|
|
128
|
+
const APP_NODES = {
|
|
129
|
+
askSmsPhone: 'askSmsPhone',
|
|
130
|
+
} as const;
|
|
131
|
+
|
|
132
|
+
@Injectable()
|
|
133
|
+
export class NeedSmsPhoneIntention extends CodeIntention {
|
|
134
|
+
public readonly name: string = APP_INTENTIONS.needSmsPhone;
|
|
135
|
+
public phase: IntentionCascadePhase = INTENTION_CASCADE_PHASE.Force;
|
|
136
|
+
public toNodeId: string = APP_NODES.askSmsPhone;
|
|
137
|
+
public priority: number = DEFAULT_FORCE_INTENTION_PRIORITY;
|
|
138
|
+
public boost: number = 0;
|
|
139
|
+
public reason: string = 'need_sms_phone';
|
|
140
|
+
|
|
141
|
+
public async before(ctx): Promise<boolean> {
|
|
142
|
+
return (
|
|
143
|
+
ctx.memory.formSendConsent === true &&
|
|
144
|
+
ctx.memory.phoneConfirmed !== true
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
// optional: match(), run(), after()
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
| Field | Role |
|
|
152
|
+
| --- | --- |
|
|
153
|
+
| `name` | Stable id (same string listens / flow / Brain use) — prefer app `as const` maps |
|
|
154
|
+
| `phase` | `INTENTION_CASCADE_PHASE.Force` \| `.Match` \| `.Scan` (default) |
|
|
155
|
+
| `boost` | Brain scoring hint |
|
|
156
|
+
| `priority` | Walk order when ranked (`DEFAULT_FORCE_INTENTION_PRIORITY` for gates) |
|
|
157
|
+
| `toNodeId` | Destination when force/match wins (optional if `run()` returns `INTENTION_RUN_KIND.Goto`) |
|
|
158
|
+
|
|
159
|
+
Framework constants: `INTENTION_CASCADE_PHASE`, `INTENTION_RUN_KIND`, `ROUTE_RESOLVED_VIA` (forensics), `STANDARD_INTENTIONS` (studio portals).
|
|
160
|
+
|
|
161
|
+
Lifecycle: `before` → `match` → `run` → `after`. YAML `transitions` remain for visualization; **code force intentions run first**.
|
|
162
|
+
|
|
163
|
+
String-only intention names in `flow.yaml` still work (`phase: scan` behavior).
|
|
164
|
+
|
|
165
|
+
## Nodes
|
|
166
|
+
|
|
167
|
+
`AgentNode<TSchema>` — declare overrides in **runtime order**:
|
|
168
|
+
|
|
169
|
+
1. `before(ctx)` — authorize / prepare; `false` rejects this candidate. Put **async prep** here (JWT/API warm, auth). Prefer spawn-without-await when first speech must stay fast; `await` only when the gate needs the result. Do not dump that I/O into `run()`.
|
|
170
|
+
2. `listen(ctx)` — register the **next** listen (intentions, hints, extract, optional `timeoutSeconds`) **before** speech so mid-talk interrupts still bind (no I/O)
|
|
171
|
+
3. `run(ctx)` — **required**; speech + exactly one terminal output action — keep integrations/teardown out unless speak cannot proceed without the result
|
|
172
|
+
4. `after(ctx, result)` — teardown for this execution (non-speech side effects; no TTS)
|
|
173
|
+
5. `catch(ctx, error)` — optional recovery (`restartNode`, `forceIntention`, `catchResult`, `rethrowCatch`)
|
|
174
|
+
|
|
175
|
+
Doctrine: [`docs/best-practices/nodes-and-listens.md`](../best-practices/nodes-and-listens.md#prefer-before--after-for-async-work).
|
|
176
|
+
|
|
177
|
+
Intention **action** names: `is{VerbInPast}…` (`isCollectedFirstName`). Polarity adjectives are fine (`studio.isPositive`).
|
|
178
|
+
|
|
179
|
+
`ctx` includes typed `memory` / `conversation.variables`, `output`, `events`, `clarify`, `judge`, `integrations`, history helpers.
|
|
180
|
+
|
|
181
|
+
### Listen timeout
|
|
182
|
+
|
|
183
|
+
How long the channel waits after a user pause before closing the listen (so ASR crumbs like `"K."` do not steal the turn from `"as soon as possible"`).
|
|
184
|
+
|
|
185
|
+
| Layer | Where | Wins over |
|
|
186
|
+
| --- | --- | --- |
|
|
187
|
+
| Framework default | `DEFAULT_LISTEN_TIMEOUT_SECONDS` (`2.5`) | — |
|
|
188
|
+
| Node class | `listenTimeoutSeconds` on the `AgentNode` subclass | default |
|
|
189
|
+
| `listen()` | `ListenExpectation.timeoutSeconds` | Node class |
|
|
190
|
+
| `sayAndListen({ timeoutSeconds })` | that turn only | `listen()` |
|
|
191
|
+
|
|
192
|
+
Clamped to `[0.4, 12]`. The Supervisor stamps the resolved value onto `runtime.listenExpectation`. Transient `assistant-request` assistants get a matching Vapi `startSpeakingPlan`. **Do not hold the Custom LLM request** when the Brain is ChatGPT — that delay plus OpenAI latency makes Vapi retry and the caller repeat. **Exception:** a listen with `interruptible: false` (address dictation) queues overlapping Custom LLM POSTs until **3s of silence** after the last fragment, then scans once. Mock Brain may still pass `listenHoldMs` to coalesce ASR crumbs. ChatGPT scans abort after **3s** and fall back to `studio.isUnknownTransition`. Target: first SSE speech **< 1.5s** after the Custom LLM request (live conversation — silence is a failure). **Speech must land on the Custom LLM HTTP request Vapi is still listening to.** A queued/coalesced waiter replays `lastAssistantSpeech`; it must not finish with empty SSE. **Shit in, shit out:** Vapi duplicate/stale Custom LLM posts are a provider artifact — do not teach scan prompts or Nodes to detect “stale repeats.” Garbage ASR and weird callers are not fully in scope; unknown / re-ask is enough.
|
|
193
|
+
|
|
194
|
+
Override on the class:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
export class AskTimelineNode extends AgentNode {
|
|
198
|
+
listenTimeoutSeconds = 3.5;
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Output (terminal invariant)
|
|
203
|
+
|
|
204
|
+
A Node may `say()` several times in one turn. It must finish with exactly one of:
|
|
205
|
+
|
|
206
|
+
- `sayAndListen(text, options?)` — speak and wait (optional `extract`, `timeoutSeconds`, `onExtracted` that **the app** writes into memory)
|
|
207
|
+
- `endCall(text?)` — ends the Conversation (Vapi → end-call tool)
|
|
208
|
+
- `transferToHuman(destination?)` — phone transfer (Vapi → `transferCall` tool)
|
|
209
|
+
- `handoff({ to, reason?, payload?, text? })` — leave this **workflow module** without ending the Conversation (`to` = module id; Vapi → Squad handoff tool). Requires a loaded `workflow.yaml`.
|
|
210
|
+
- `continueTo({ nodeId, reason?, text? })` — jump to another Node in the **same** loaded flow and speak its entry next (Studio / single-assistant Vapi). Emits `FLOW_CONTINUE`; does **not** emit a Squad handoff tool.
|
|
211
|
+
- `invokeAdvertisedTool({ name, arguments?, text? })` — request a **Vapi assistant tool by function name** (must already exist on `model.tools`). Compiles to OpenAI `tool_calls` SSE. Use for app tools (SMS, CRM, …) when semantic helpers (`endCall`, `transferToHuman`, `handoff`) do not apply. `toolCall` is a deprecated alias.
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
// Prefer semantic helpers when they apply:
|
|
215
|
+
await ctx.output.endCall('Goodbye.');
|
|
216
|
+
await ctx.output.transferToHuman('+15551234567');
|
|
217
|
+
await ctx.output.continueTo({ nodeId: 'identityCollect' });
|
|
218
|
+
await ctx.output.handoff({ to: 'identity' }); // Squad only — needs workflow.yaml
|
|
219
|
+
|
|
220
|
+
// App tool — name must match a function on the live Vapi assistant:
|
|
221
|
+
return ctx.output.invokeAdvertisedTool({
|
|
222
|
+
name: 'send_sms_form',
|
|
223
|
+
arguments: { to: ctx.memory.contactPhone, formId: 1 },
|
|
224
|
+
text: 'I am texting that form now.', // optional speech before the tool call
|
|
225
|
+
});
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`endCall` / `transferToHuman` / `handoff` resolve tool names via adapter env. `invokeAdvertisedTool` emits the exact `name` you pass — it does **not** sync or invent the assistant tool list. Provision matching tools on the Vapi assistant before you call.
|
|
229
|
+
|
|
230
|
+
### Tasks (`ctx.tasks.dispatch` / `require`)
|
|
231
|
+
|
|
232
|
+
I/O counterpart to the speech phrase pool ([`CallTurnQueue`](../../src/conversation/call-turn-queue.ts)). The queue is **conversation-scoped** (survives node transitions).
|
|
233
|
+
|
|
234
|
+
- **`dispatch`** — `mode: 'wait'` blocks this turn; `mode: 'async'` returns a handle and continues (user speech still queues). Optional `dedupeKey` coalesces in-flight work and is the handle for later nodes.
|
|
235
|
+
- **`require(dedupeKey | { taskId, dedupeKey })`** — postponed debt: a later node waits for work a previous node started async. Already-finished tasks resolve immediately; still-running emits `TASK_AWAITED` then waits.
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
// Node A — start work, keep talking
|
|
239
|
+
await ctx.tasks.dispatch({
|
|
240
|
+
kind: 'crm.slots.lookup',
|
|
241
|
+
mode: 'async',
|
|
242
|
+
dedupeKey: `availability:${ctx.conversation.id}`,
|
|
243
|
+
run: () => fetchAppointmentAvailability(dayHint),
|
|
244
|
+
});
|
|
245
|
+
return ctx.output.continueTo({
|
|
246
|
+
nodeId: 'offerSlots',
|
|
247
|
+
text: 'One moment — checking openings.',
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Node B — dependency: must have the result before listing slots
|
|
251
|
+
const slots = await ctx.tasks.require(`availability:${ctx.conversation.id}`);
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Lifecycle events (persist): `TASK_ENQUEUED`, `TASK_STARTED`, `TASK_COMPLETED`, `TASK_FAILED`, `TASK_DEDUPED`, `TASK_AWAITED`. Never speak `taskId`s. Prefer async + a short spoken bridge when the next node can absorb the wait; use `require` at the node that needs the result.
|
|
255
|
+
|
|
256
|
+
### Forms (`ctx.forms.expose`)
|
|
257
|
+
|
|
258
|
+
Blocking structured collection. Apps deliver via a dispose adapter — Studio modal, **first-party HTML** (`renderFormHtml` + `GET/POST /forms/:exposeId`), or **Twilio SMS** (`TwilioSmsFormDisposeAdapter`, `branch: sms`).
|
|
259
|
+
|
|
260
|
+
The dispose adapter is the **sendout driver**. It declares a delivery `branch` (`html_link`, `sms`, `unavailable`, …). The app may also pass a conversation `branch` on expose (e.g. `identity_html_form`) so logs show which flow lane requested the form.
|
|
261
|
+
|
|
262
|
+
**Twilio SMS (prepared):** reserved env `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_FROM_NUMBER` or `TWILIO_MESSAGING_SERVICE_SID`, and `TWILIO_SMS_DRY_RUN` (default **on**). Sends emit `OUTBOUND_NOTIFICATION` (and `OUTBOUND_NOTIFICATION_ERROR` on failure) via `EventService` (in-process Node bus + Nest listeners). Dry-run still emits with `status: dry_run`. Live send needs optional peer `twilio` and `TWILIO_SMS_DRY_RUN=0`. `disposeContext`: `contactPhone` (optional `formUrl` — defaults to `{PUBLIC_BASE_URL}/forms/{exposeId}`; optional `body`). See [Forms](../guide/forms.md) and [environment variables](./environment-variables.md).
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
const values = await ctx.forms.expose({
|
|
266
|
+
formId: 1,
|
|
267
|
+
branch: 'identity_html_form', // conversation lane (forensics)
|
|
268
|
+
fields: [
|
|
269
|
+
{ name: 'firstName', label: 'First name', type: 'string', required: true },
|
|
270
|
+
{ name: 'address', label: 'Address', type: 'textarea', required: true },
|
|
271
|
+
],
|
|
272
|
+
disposeContext: { contactPhone: '5550100999', channel: 'phone' },
|
|
273
|
+
onDelivered: async () => {
|
|
274
|
+
await ctx.output.say('I exposed a form — please fill it out. I will wait.');
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
1. **`FORM_SENDOUT` (persist, early)** — `branch` + `deliveryBranch` + adapter id **before** ACK/fillout wait. Console tag **FORM**.
|
|
280
|
+
2. Dispose adapter delivers (`VapiStudioModule.forRoot({ formDisposeAdapter })`). Adapter `readonly branch` is the delivery lane. Optional `disposeContext` is opaque app data.
|
|
281
|
+
3. Framework waits **≤ 15s** for channel ACK (`FormsService.ack`) — else `FormDeliverTimeoutError`. Adapters may ACK when a link is ready.
|
|
282
|
+
4. `onDelivered` runs; then either:
|
|
283
|
+
- **`expose()` (blocking)** — waits for submit (Studio-friendly; keeps the channel turn open).
|
|
284
|
+
- **`open()` (preferred on live Vapi)** — returns after delivery so the Custom LLM turn can finish (`stop`/`[DONE]`), TTS can complete, and silence timers do not fight a multi-minute hold. Later: `submit` → `claimSubmitted()` (or FormResume / still-there).
|
|
285
|
+
5. **`ctx.forms.resend()`** — re-runs dispose on the open (unsubmitted) expose when the caller says they never got the SMS / link. Same `exposeId` + stored `disposeContext`; emits `FORM_RESEND` (persist) + `FORM_RESENT`. Returns `null` if nothing is pending.
|
|
286
|
+
6. Events: `FORM_SENDOUT`, `FORM_EXPOSE`, `FORM_DELIVERED`, `FORM_RESEND` / `FORM_RESENT`, `FORM_DELIVER_TIMEOUT`, `FORM_SUBMITTED`, `FORM_FILLOUT_TIMEOUT` (plus app `FORM_LINK_READY`).
|
|
287
|
+
7. HTML helper: `renderFormHtml(handle)` / `renderFormThanksHtml()` / `renderFormGoneHtml()` — JSON fields → page; apps own the HTTP routes.
|
|
288
|
+
8. `ctx.forms.hasPending()` / `hasUnclaimedSubmit()` / `claimSubmitted()` / `resend()` — ghosting must no-op while pending; claim after submit on the open path; resend when delivery failed on the caller’s device.
|
|
289
|
+
|
|
290
|
+
Default dispose is **noop** (`branch: unavailable` — ACK never arrives → Nodes fall back to voice).
|
|
291
|
+
|
|
292
|
+
The Vapi adapter compiles those actions to OpenAI-compatible SSE + tool calls (`end_call_tool`, `transferCall`, Squad `handoff`). Tool names are env-configurable (`VAPI_HANDOFF_TOOL_NAME`, default `handoff`). Handoff does **not** finalize the Conversation.
|
|
293
|
+
|
|
294
|
+
**Vapi-compatible handoff tool call:** Custom LLM SSE must call the advertised handoff function with **`destination` as a string** (the Squad member `assistantName`), e.g. `{"destination":"IdentityLane"}`. Do not send `{ type, assistantName }` objects — that is assistant *config*, not the tool-call argument. Prefer destination-specific tools (`handoff_to_<Name>`) when Vapi advertises them. Each Squad member assistant must declare `type: "handoff"` tools with `destinations[].assistantName` matching `workflow.yaml` (put tools on the assistant `model.tools[]`, not only in the Squad builder UI).
|
|
295
|
+
|
|
296
|
+
## Workflow (Squad) + modules
|
|
297
|
+
|
|
298
|
+
A **workflow** is the Vapi Studio equivalent of a Vapi Squad: one durable Conversation with shared memory, history, portals, events, and checkpoints. Each **module** is a Squad member (own `flow.yaml` + Nodes; Custom LLM URL or native Vapi assistant).
|
|
299
|
+
|
|
300
|
+
App-owned `config/workflow.yaml`:
|
|
301
|
+
|
|
302
|
+
```yaml
|
|
303
|
+
version: 1
|
|
304
|
+
workflow:
|
|
305
|
+
id: my-workflow
|
|
306
|
+
entryModule: router
|
|
307
|
+
modules:
|
|
308
|
+
router:
|
|
309
|
+
assistantName: MyRouter
|
|
310
|
+
kind: studio
|
|
311
|
+
flowFile: modules/router.flow.yaml
|
|
312
|
+
entryNode: routerTriage # optional; handoffs use this (opening still uses flow.start)
|
|
313
|
+
identity:
|
|
314
|
+
assistantName: MyIdentity
|
|
315
|
+
kind: studio
|
|
316
|
+
flowFile: modules/identity.flow.yaml
|
|
317
|
+
farewell:
|
|
318
|
+
assistantName: MyFarewell
|
|
319
|
+
kind: studio
|
|
320
|
+
flowFile: modules/farewell.flow.yaml
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
- `WorkflowLoader` — load / resolve module id ↔ Vapi `assistantName`
|
|
324
|
+
- `WorkflowHandoffService.applyHandoffs` — enrich actions with `assistantName`, set `metadata.activeModuleId`, load the next Vapi Studio flow, emit `WORKFLOW_HANDOFF`, checkpoint; **never** `finalizeEnded`
|
|
325
|
+
- After handoff, `metadata.moduleNeedsEntrySpeak` — Supervisor speaks the destination entry Node on the next empty/module-entry turn (Studio may do this in-process; Vapi after Squad switch)
|
|
326
|
+
- One deployment can serve Squad members via `POST /vapi/:moduleId/chat/completions` or header `X-Vapi-Studio-Module`
|
|
327
|
+
|
|
328
|
+
Native Vapi members use `kind: vapi` (no flow file) — Vapi Studio only emits the handoff tool; Vapi owns that assistant.
|
|
329
|
+
|
|
330
|
+
Portals stay globally eligible in every module (same runtime).
|
|
331
|
+
|
|
332
|
+
### Listen / extract
|
|
333
|
+
|
|
334
|
+
`listen()` / `sayAndListen({ extract })` tell Brain what to score and which JSON fields to pull. The framework does not auto-assign memory: `onExtracted` is application-owned. Listen timeout is documented above.
|
|
335
|
+
|
|
336
|
+
**Closed choice (`resolveIntention`)** — after the bot listed numbered options, attach a cheap matcher on the listen. If it returns an intention name that is on that listen, Supervisor **skips Brain** and walks that intention at confidence 1 (no 3s abort). Return `null` to fall through to Brain so portals and unknown still work. Use this for pick-N / neither, not for free-form answers. A skipped scan still advances `brainSequenceIndex` so MockBrain sequences stay aligned.
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
return ctx.output.sayAndListen('I have two matches. Which one is correct?', {
|
|
340
|
+
resolveIntention: ({ userText, memory }) => {
|
|
341
|
+
// 'isConfirmedAddressMatch' | 'isRejectedAddressMatch' | null
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
## Flow YAML
|
|
347
|
+
|
|
348
|
+
YAML describes **paths**, not implementations. No URLs, no integration payloads.
|
|
349
|
+
|
|
350
|
+
```yaml
|
|
351
|
+
version: 1
|
|
352
|
+
flow:
|
|
353
|
+
id: my-bot
|
|
354
|
+
# Greeting — first speak (opening turn). Inject/prep is ConversationEntryPoint, not a prior Node.
|
|
355
|
+
start: acknowledge
|
|
356
|
+
nodes:
|
|
357
|
+
acknowledge:
|
|
358
|
+
class: AcknowledgeNode
|
|
359
|
+
intentions:
|
|
360
|
+
- isAcknowledged
|
|
361
|
+
askSmsPhone:
|
|
362
|
+
class: AskSmsPhoneNode
|
|
363
|
+
intentions:
|
|
364
|
+
- isCollectedPhone
|
|
365
|
+
identityCollect:
|
|
366
|
+
class: IdentityCollectNode
|
|
367
|
+
intentions:
|
|
368
|
+
- isIdentityCollect
|
|
369
|
+
goodbye:
|
|
370
|
+
class: GoodbyeNode
|
|
371
|
+
intentions:
|
|
372
|
+
- studio.isGoodbye
|
|
373
|
+
terminal: true
|
|
374
|
+
transferToHuman:
|
|
375
|
+
class: TransferToHumanNode
|
|
376
|
+
portal: true
|
|
377
|
+
priority: 100
|
|
378
|
+
intentions:
|
|
379
|
+
- studio.isTransferToHuman
|
|
380
|
+
|
|
381
|
+
# Optional — declarative gates (visualizable; OK to duplicate node continueTo).
|
|
382
|
+
transitions:
|
|
383
|
+
- id: needSmsPhone
|
|
384
|
+
from: [identityCollect, routerTriage]
|
|
385
|
+
to: askSmsPhone
|
|
386
|
+
when: memory.formSendConsent == true && memory.phoneConfirmed != true
|
|
387
|
+
force: true
|
|
388
|
+
reason: need_sms_phone
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
- Exactly one `start`
|
|
392
|
+
- `class` maps to `VapiStudioModule.forRoot({ nodes: [{ className, useClass }] })`
|
|
393
|
+
- `portal: true` — eligible every turn, not only from the current listen
|
|
394
|
+
- `priority` on the node feeds Supervisor walk (with listen-level priority)
|
|
395
|
+
- **`transitions`** — condition edges evaluated each turn against `memory.*` / `variables.*` (alias `var.*`):
|
|
396
|
+
- `when`: boolean expression (`==`, `!=`, `!`, `&&`, `||`, parentheses). Missing keys are falsy; `== undefined` / `== null` are nullish.
|
|
397
|
+
- `from`: optional allow-list of current node ids (omit = any node except `to`)
|
|
398
|
+
- `to`: destination node id (must exist)
|
|
399
|
+
- `force: true`: cascade step 1 — skip listen resolve + Brain (`studio.goto.<to>`)
|
|
400
|
+
- `force: false` / omitted: soft boost into the Brain walk (default priority `50`)
|
|
401
|
+
- `reason` / `id`: forensics (`CONDITION_TRANSITION`)
|
|
402
|
+
|
|
403
|
+
Prefer **dedicated Nodes** for gap-fill (phone, consent, name) and declare the gate in `transitions` so paths stay visible even if Nodes also `continueTo` the same place (DRY breach is fine when determinism matters).
|
|
404
|
+
|
|
405
|
+
## Brain
|
|
406
|
+
|
|
407
|
+
Port (`BrainService` / `BrainAdapter`) — Supervisor depends only on this:
|
|
408
|
+
|
|
409
|
+
| Method | Use |
|
|
410
|
+
| --- | --- |
|
|
411
|
+
| `scan` | Rank candidates + optional `extracted` map |
|
|
412
|
+
| `clarify` | Structured Q&A; always `{ answer: object }` |
|
|
413
|
+
| `judge` | LLM-as-a-judge for **evals**; rare on the live path |
|
|
414
|
+
|
|
415
|
+
Judge result: `{ passed, confidence, reasoning, belowThreshold }`. `confidence` is always `/^\d\.\d{6}$/`. Threshold does **not** flip `passed`. Trusted pass = `passed && !belowThreshold`. Paid e2e evals are not in the current delivery slice.
|
|
416
|
+
|
|
417
|
+
Stock adapters:
|
|
418
|
+
|
|
419
|
+
**Live (JSON-LLM, cheap whitelist only):**
|
|
420
|
+
|
|
421
|
+
- `ChatGptBrainAdapter` — OpenAI (`gpt-4.1-nano`, `gpt-4o-mini`, `gpt-4.1-mini`, `gpt-5.4-nano`). Env: `OPENAI_API_KEY`.
|
|
422
|
+
- `ClaudeBrainAdapter` — Anthropic Haiku (`claude-haiku-4-5-20251001`, `claude-3-5-haiku-latest`, …). Env: `ANTHROPIC_API_KEY`.
|
|
423
|
+
- `GeminiBrainAdapter` — Gemini Flash (`gemini-2.0-flash`, `gemini-2.0-flash-lite`, …). Env: `GOOGLE_API_KEY` (or `GEMINI_API_KEY`).
|
|
424
|
+
- `GrokBrainAdapter` — xAI Grok (`grok-3-mini`, `grok-4-fast-non-reasoning`, …) via OpenAI-compatible API. Env: `XAI_API_KEY`.
|
|
425
|
+
|
|
426
|
+
**Mock (deterministic sequences / profiles; no provider network):**
|
|
427
|
+
|
|
428
|
+
- `MockBrainAdapter` — generic PoC default
|
|
429
|
+
- `MockChatGptBrainAdapter` / `MockClaudeBrainAdapter` / `MockGeminiBrainAdapter` / `MockGrokBrainAdapter` — same engine, tagged by intended live provider for app wiring (`mockProvider`)
|
|
430
|
+
|
|
431
|
+
All JSON-LLM stock adapters share the same scan/clarify/judge contracts: HTTP timeout **3s**; scan failure → unknown; live path with no pre-scan hold; first speech target **< 1.5s**. Do not special-case Vapi stale repeats in the scan prompt. Enable in **application code** via `brainAdapter`.
|
|
432
|
+
|
|
433
|
+
**Prompt injection:** Brain never streams prose to the caller. Scan/clarify/judge use `brainUntrustedInputRules()`, JSON-only responses, intention allowlists, and `sanitizeExtractedFieldValue()` on extract (clarify answer strings too). Stock JSON-LLM adapters **fail closed** before the provider when `utteranceLooksLikePromptInjection()` matches (scan → unknown / `prompt_injection_blocked`; clarify → `cannotAnswer`). Caller payloads use `untrustedCallerText`. Agent steps must not speak raw ASR or Brain `reason`. See [brain-and-prompt-injection.md](../best-practices/brain-and-prompt-injection.md).
|
|
434
|
+
|
|
435
|
+
Exports: `brainUntrustedInputRules`, `looksLikePromptInjection`, `utteranceLooksLikePromptInjection`, `sanitizeExtractedFieldValue`, `wrapUntrustedUserText`.
|
|
436
|
+
|
|
437
|
+
### Email PII helpers (boxed)
|
|
438
|
+
|
|
439
|
+
Voice / ASR email recovery — use on listen extracts; fail closed before writing memory.
|
|
440
|
+
|
|
441
|
+
**Import (preferred):**
|
|
442
|
+
|
|
443
|
+
```ts
|
|
444
|
+
import {
|
|
445
|
+
EMAIL_EXTRACT_DESCRIPTION,
|
|
446
|
+
collectedEmail,
|
|
447
|
+
parseSpelledEmail,
|
|
448
|
+
} from '@guidify-ai/vapi-studio/identity';
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Also available from `@guidify-ai/vapi-studio`.
|
|
452
|
+
|
|
453
|
+
| Export | Role |
|
|
454
|
+
| --- | --- |
|
|
455
|
+
| `EMAIL_EXTRACT_DESCRIPTION` | GOT string for `extract.fields[].description` (normal + spelled ASR) |
|
|
456
|
+
| `parseSpelledEmail(raw)` | Direct `name@domain` or spelled (`mark at example dot com`, letter-by-letter) → address or `null` |
|
|
457
|
+
| `collectedEmail(extracted, spoken)` | Prefer Brain extract, then spoken |
|
|
458
|
+
|
|
459
|
+
See [identity-and-pii.md](../best-practices/identity-and-pii.md).
|
|
460
|
+
|
|
461
|
+
Apps may supply their own adapter (e.g. HTTP Brain) via `brainAdapter`.
|
|
462
|
+
|
|
463
|
+
Brain **model** and **scan threshold** are `VapiStudioModule.forRoot({ brain })` — model must be on the **selected adapter’s** cheap whitelist (default when omitted). Provider **API keys** belong in `.env`. End of call may log `BRAIN_COST_SUMMARY` when a stock LLM adapter recorded usage.
|
|
464
|
+
|
|
465
|
+
## Studio UI (operator SPA)
|
|
466
|
+
|
|
467
|
+
### Identity contract (hard)
|
|
468
|
+
|
|
469
|
+
See [ui-and-api-identity.md](../best-practices/ui-and-api-identity.md).
|
|
470
|
+
|
|
471
|
+
1. Studio FE ↔ Nest JSON identifies records by **`uuid` only** (never internal numeric/`bigint` `id`).
|
|
472
|
+
2. Durable entities store **`id` + `uuid`** — `id` for joins/filters/sorts; `uuid` for HTTP and other external comms.
|
|
473
|
+
3. List/detail DTOs always include a human **`label`** so the UI can title rows without printing UUIDs.
|
|
474
|
+
|
|
475
|
+
Route params and JSON fields for resource identity use the name `uuid` (not `id`) on new/changed APIs. Transitional tables may still use UUID-as-PK named `id` — migrate when touching persistence.
|
|
476
|
+
|
|
477
|
+
Mount the shared React Studio SPA (Flow / Conversations) from the package:
|
|
478
|
+
|
|
479
|
+
```ts
|
|
480
|
+
// app.module.ts
|
|
481
|
+
imports: [StudioUiModule.forRoot(), VapiStudioModule.forRoot({ ... })]
|
|
482
|
+
|
|
483
|
+
// main.ts
|
|
484
|
+
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
|
485
|
+
mountStudioUiAssets(app);
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
Routes (client SPA + Nest JSON APIs):
|
|
489
|
+
|
|
490
|
+
| UI | JSON |
|
|
491
|
+
| --- | --- |
|
|
492
|
+
| `GET /flow` | `GET /flow/graph`, `PUT /flow/edges`, `/studio/*` |
|
|
493
|
+
| `GET /conversations`, `/conversations/:uuid` | `GET /conversations/api`, `/conversations/api/:uuid` |
|
|
494
|
+
|
|
495
|
+
Analytics dashboards are not part of the Studio operator SPA. Stamp tags via
|
|
496
|
+
`persistAnalyticsTag`; export events from application code if needed
|
|
497
|
+
([Extending events](../guides/extending-events.md)).
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
Package-owned **names** (behavior is always an application Node):
|
|
501
|
+
|
|
502
|
+
| Constant | Name |
|
|
503
|
+
| --- | --- |
|
|
504
|
+
| `STANDARD_INTENTIONS.isGoodbye` | `studio.isGoodbye` |
|
|
505
|
+
| `STANDARD_INTENTIONS.isTransferToHuman` | `studio.isTransferToHuman` |
|
|
506
|
+
| `STANDARD_INTENTIONS.isPause` | `studio.isPause` |
|
|
507
|
+
| `STANDARD_INTENTIONS.isMad` | `studio.isMad` |
|
|
508
|
+
| `STANDARD_INTENTIONS.isUnknownTransition` | `studio.isUnknownTransition` |
|
|
509
|
+
| `STANDARD_INTENTIONS.isPositive` | `studio.isPositive` |
|
|
510
|
+
| `STANDARD_INTENTIONS.isNegative` | `studio.isNegative` |
|
|
511
|
+
| `STANDARD_INTENTIONS.isStillThere` | `studio.isStillThere` |
|
|
512
|
+
| `STANDARD_INTENTIONS.isToolResult` | `studio.isToolResult` |
|
|
513
|
+
|
|
514
|
+
Portals are global Nodes (`portal: true`). Transfer-to-human typically re-engages **once** in memory, then emits `transferToHuman`. Still-there tracks `portalState.stillThere.attempts` (ask ×2, then `endCall`). Portal counters are in-memory on the active runtime, not the durable turn source of truth.
|
|
515
|
+
|
|
516
|
+
**Mad is sticky for one re-engage:** once in the `mad` portal, Supervisor does not offer other portals (or unknown) until the caller continues / goodbyes / transfers. Apps should **not** keep de-escalating forever — typical policy is one apology listen, then transfer on a second mad hit. Other listens may still escalate *into* mad via `studio.isMad`.
|
|
517
|
+
|
|
518
|
+
**Still-there is force-only:** `studio.isStillThere` may enter the portal only via `handleTurn({ forceIntention })` (idle / Vapi speech-timeout). That path stamps `resolvedVia = force_intention` so the walk accepts still-there; Brain must not promote filler like “hey?” into still-there mid-flow. While in the portal, `studio.isPositive` / `isContinue` **Continue-replays** the origin listen (same utterance) — do not steal a new “how can I help?” CTA.
|
|
519
|
+
|
|
520
|
+
**`isContinue` / ContinueNode:** not a spoken filler. When the walk selects Continue, Supervisor **exits the portal**, restores `originNodeId`, refreshes that Node’s listen (preferring the **listen snapshot** taken when the portal opened — so `sayAndListen` overrides and `resolveIntention` survive), re-scans the **same** user utterance, and routes again (skips re-selecting `isContinue` to avoid a loop). Apps must not ship “Okay, continuing.” as Continue behavior.
|
|
521
|
+
|
|
522
|
+
**Unknown origin consume:** While `studio.isUnknownTransition` is the active portal, the **next** user utterance is first matched against the **origin listen** (same snapshot). If `resolveIntention` or Brain yields a walkable non-unknown intention, Supervisor exits the portal and routes that intention on **this turn** (`ROUTE_DECISION.resolvedVia = unknown_origin_consume`) — it must not steal the turn via unknown’s thin candidate list (e.g. mapping “as soon as possible” to an unrelated roof-estimate path). If the origin still cannot accept the utterance, the normal unknown listen / recovery path runs.
|
|
523
|
+
|
|
524
|
+
## Vapi integration
|
|
525
|
+
|
|
526
|
+
### Vapi owns the call / Vapi Studio owns the logic
|
|
527
|
+
|
|
528
|
+
| Owner | Responsibility |
|
|
529
|
+
| --- | --- |
|
|
530
|
+
| **Vapi** | Telephony, TTS, STT, Tools **host + execution**, Squads, **Evals**, phone numbers, silence clock (`customer.speech.timeout`) |
|
|
531
|
+
| **Vapi Studio** | Deterministic structure, memory, extraction/clarify, when to request tools/handoffs/endCall, Conversation portals |
|
|
532
|
+
|
|
533
|
+
**Do not reimplement Vapi Evals** in Vapi Studio. Unit/schema/runtime tests live here; voice E2E belongs in Vapi Evals. Local Flow Studio substitutes for Custom LLM logic without a live call — it is not a second Evals platform.
|
|
534
|
+
|
|
535
|
+
#### Tool convention (expected desync)
|
|
536
|
+
|
|
537
|
+
The only durable “desync” between Vapi and Vapi Studio is **which tools the assistant really has**. That is a **config convention**, not shared runtime state:
|
|
538
|
+
|
|
539
|
+
1. Nodes / semantic helpers **request tools by name** (`endCall`, `transferToHuman`, `handoff`, `invokeAdvertisedTool({ name })`) — assuming the live assistant was provisioned with them.
|
|
540
|
+
2. The **Vapi assistant** (Squad member `model.tools[]`, hooks, server tools) must be **pre-provided** with those tools. Vapi Studio does not sync or invent the inventory.
|
|
541
|
+
3. When Custom LLM requests include `body.tools`, Vapi Studio may stash them as `ctx.tools` for **optional** visibility / operator warnings (`TOOL_CALL_NOT_ADVERTISED`). Nodes should **not** treat `ctx.tools` as a required gate — empty ads or Studio turns are normal.
|
|
542
|
+
|
|
543
|
+
Vapi treats the app as an OpenAI-compatible Custom LLM.
|
|
544
|
+
|
|
545
|
+
- Webhook: server messages (`assistant-request`, `assistant.started`, `status-update`, `user-interrupted`, `tool-calls`, …). Strategies live in the **app**; helpers live here (`extractVapiCallId`, `extractVapiCallerNumber`, `extractAdvertisedTools`, `extractToolResults`).
|
|
546
|
+
- Custom LLM: `POST …/chat/completions` — **one request per turn**, SSE stream, then close. `VapiSseCompiler` writes chunks and terminal tool calls (`endCall`, `transferCall`, Squad `handoff`, plus `invokeAdvertisedTool` → internal `toolCall` action). Mock Brain may hold `listenTimeoutSeconds` to coalesce ASR crumbs; ChatGPT must not — scan starts immediately.
|
|
547
|
+
- Each Custom LLM turn may stash `body.tools` → `ctx.tools` (optional ads), `role:tool` → `ctx.toolResult(s)`, and call metadata → `ctx.vapi`. Tool-result-only turns (no new user speech) **re-enter the Node** that emitted `invokeAdvertisedTool` without a Brain scan of empty text. Idle / still-there uses `Supervisor.handleTurn({ forceIntention: 'studio.isStillThere' })` (Studio timer or server tool from Vapi speech-timeout hooks) — **not** `listenTimeoutSeconds`.
|
|
548
|
+
- **LLM-requested tools** (Custom LLM SSE `tool_calls`) vs **server-dispatched tools** (webhook `message.type === 'tool-calls'`) are different paths; apps implement the latter in a webhook strategy.
|
|
549
|
+
- Workflow modules: prefer one app with per-member URLs (`/vapi/:moduleId/chat/completions`) or `X-Vapi-Studio-Module` so the same Conversation + `activeModuleId` serve the Squad. Squad handoff *rules* live on each Vapi assistant (`model.tools` type `handoff`); Vapi Studio only emits the matching tool call when a Node returns `output.handoff`.
|
|
550
|
+
- Helpers: `buildVapiHandoffToolArgs`, `hasAdvertisedHandoffTool`, `resolveHandoffToolName(tools, assistantName)`.
|
|
551
|
+
- Correlation: call id from body/headers. Missing call id is an error, not a silent new Conversation.
|
|
552
|
+
|
|
553
|
+
Nodes speak through `ConversationOutput`. They never format SSE.
|
|
554
|
+
|
|
555
|
+
```ts
|
|
556
|
+
// Vapi assistant must already expose this function name on model.tools:
|
|
557
|
+
return ctx.output.invokeAdvertisedTool({
|
|
558
|
+
name: 'lookupCustomer',
|
|
559
|
+
arguments: { phone: ctx.vapi.phoneNumber },
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
// Next Custom LLM request after Vapi runs the tool:
|
|
563
|
+
const result = ctx.toolResult('lookupCustomer');
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
## Integrations
|
|
567
|
+
|
|
568
|
+
```ts
|
|
569
|
+
await ctx.integrations.request({
|
|
570
|
+
name: 'example.slots',
|
|
571
|
+
url: 'https://example.test/v1/slots',
|
|
572
|
+
body: { day: 'tomorrow' },
|
|
573
|
+
secretEnvKey: 'EXAMPLE_JWT_SECRET', // name of the .env key, not the secret
|
|
574
|
+
});
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
JWT HS256 of the canonical body bytes in `x-signature` (Node `crypto`, no `jsonwebtoken`). Events: `INTEGRATION_REQUEST` / `INTEGRATION_RESPONSE` / `INTEGRATION_ERROR`.
|
|
578
|
+
|
|
579
|
+
## Events, console, daily logs
|
|
580
|
+
|
|
581
|
+
**Doctrine:** logs + structured events must be enough to answer any question about a call. See [`docs/best-practices/debugging-and-observability.md`](../best-practices/debugging-and-observability.md).
|
|
582
|
+
|
|
583
|
+
Vapi Studio is **event-driven**. `EventService.emit` / `.log` → pretty conversation console. `emit` / `persist` also fan out to:
|
|
584
|
+
|
|
585
|
+
- **Node EventEmitter** — `onStudioEvent(handler)` for any process-level listener
|
|
586
|
+
- **Nest `eventListeners`** — `VapiStudioModule.forRoot({ eventListeners: [...] })` for DI services (`StudioEventListener`)
|
|
587
|
+
|
|
588
|
+
Build custom logic on those hooks (queues, warehouses, CRM, metrics). Guidify AI companion tools integrate the same way. The OSS package does **not** ship a durable event store or remote event API — durability is application-owned. Guide: [`docs/guides/extending-events.md`](../guides/extending-events.md).
|
|
589
|
+
|
|
590
|
+
- `persistAnalyticsTag(conversationId, tag, payload?)` → type `ANALYTICS_TAG` with `payload.tag`. Stamp milestones with a stable tag id — **do not** put funnel membership on the event (`payload.funnels`).
|
|
591
|
+
- Conversation aggregates that remain in Studio Postgres: `countConversationsByProject`, `callDurationPercentiles` (on `ConversationRepository`)
|
|
592
|
+
- Console: `STUDIO_CONSOLE_DEBUG` (default on). Disable with `0` / `false` / `off`
|
|
593
|
+
- File driver: still prints console; also appends ANSI-stripped lines to `{LOG_DIR}/dailyYYYYMMDD.log`
|
|
594
|
+
- **The log directory is owned by the application** (each project keeps a `logs/` folder and sets `LOG_DIR`). The framework only writes there.
|
|
595
|
+
- Retention: `LOG_DAYS` (default **14**). Files at or older than that are deleted
|
|
596
|
+
- Each call writes a banner once:
|
|
597
|
+
|
|
598
|
+
```text
|
|
599
|
+
--------------------
|
|
600
|
+
Call ID: {vapi_call_id}
|
|
601
|
+
Caller Phone Number: {webhook customer number, or blank}
|
|
602
|
+
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
### Forensic turn events (shipped)
|
|
606
|
+
|
|
607
|
+
| Type | Pipe | Answers |
|
|
608
|
+
| --- | --- | --- |
|
|
609
|
+
| `CUSTOM_LLM_TURN` / `OPENING_TURN` | log | User text + memory diff |
|
|
610
|
+
| `CONDITION_TRANSITION` | **persist** | Matched `flow.yaml` transition (`force` / soft), when, from→to — console **FORCE** / **WHEN** |
|
|
611
|
+
| `INTENTION_FORCE` / `INTENTION_MATCH` | **persist**/log | Code `CodeIntention` cascade — console **IFORCE** / **IMATCH** |
|
|
612
|
+
| `FORM_SENDOUT` | **persist** | Early: conversation `branch` + adapter `deliveryBranch` before ACK — console **FORM** |
|
|
613
|
+
| `LISTEN_RESOLVED` / Brain scan | log | Intention source + scores |
|
|
614
|
+
| `INTENTION_SCAN` | log | Candidates, listen, threshold, memory |
|
|
615
|
+
| **`ROUTE_DECISION` / `ROUTE_FAILED`** | **persist** | Walk order, listen boosts, `rejected[]` (`before_false` / `missing`), winner, `resolvedVia` (`condition_force` / `condition_boost` / `listen_resolve` / `brain` / `route_fallback_unknown`), `userText`, memory. If every candidate’s `before()` refuses, Supervisor falls back once to `studio.isUnknownTransition` instead of throwing (Custom LLM must not 500). |
|
|
616
|
+
| `NODE_ENTER` / `NODE_AFTER` | log | Enter + result detail (`continueTo` target/reason, say text, actions) |
|
|
617
|
+
| `FLOW_CONTINUE` | persist | Same-flow jump from/to/reason |
|
|
618
|
+
| `NODE_REJECT` | log | Single `before()` refusal (also rolled into `ROUTE_DECISION.rejected`) |
|
|
619
|
+
| `FORM_*` | log/persist | Expose / deliver / submit / timeouts (carry `branch` + `deliveryBranch`) |
|
|
620
|
+
| `ANALYTICS_TAG` | **persist** | Milestone (`payload.tag`). Stamps do not need `funnels` |
|
|
621
|
+
|
|
622
|
+
Memory console diffs include conversation-critical flags such as `introSpoken` (only bootstrap infra keys are stripped).
|
|
623
|
+
|
|
624
|
+
Disable file logs with `STUDIO_FILE_LOG=0`. Tests skip files unless `LOG_DIR` is set.
|
|
625
|
+
|
|
626
|
+
## Persistence (PoC)
|
|
627
|
+
|
|
628
|
+
TypeORM + PostgreSQL (Studio / app DB):
|
|
629
|
+
|
|
630
|
+
- `projects` — durable project identity (name/slug); internal row id for DB FKs — not a public URL segment
|
|
631
|
+
- `conversations` — durable identity (`project_id` + `provider_call_id` unique), status, metadata, final snapshot
|
|
632
|
+
- `provider_ingress` — raw webhook / Custom LLM bodies for operator inspection (`project_id` when scoped)
|
|
633
|
+
|
|
634
|
+
Event history is not a Studio TypeORM table. See [Extending events](../guides/extending-events.md).
|
|
635
|
+
|
|
636
|
+
Vapi HTTP routes are **app-owned** and host-scoped: `/vapi/webhook` and `/vapi/chat/completions` (optional `/vapi/:moduleId/chat/completions`). Each fork of the starter is a separate deploy with its own `PUBLIC_BASE_URL` / port — no project UUID in the path. Vapi `assistantId` is forensics only — not used for project routing.
|
|
637
|
+
|
|
638
|
+
Active-call state (portal counters, listen registration, turn queue) stays **in memory**.
|
|
639
|
+
|
|
640
|
+
## Public API (start here)
|
|
641
|
+
|
|
642
|
+
Export surface is `src/index.ts` (implementation modules: `vapi-studio.module.ts`, `agent-node.ts`, `code-intention.ts`, `studio-event.ts`). Important groups:
|
|
643
|
+
|
|
644
|
+
- **Lint** — `yarn lint` enforces explicit access modifiers and typed class properties on `src/`
|
|
645
|
+
|
|
646
|
+
- `VapiStudioModule` / `VapiStudioModuleOptions`, `AgentNode` / `AgentNodeRegistry` / `STUDIO_NODE_REGISTRY`, `NodeContext`, `Supervisor`
|
|
647
|
+
- `CodeIntention` / `CodeIntentionRegistry` / `STUDIO_INTENTION_REGISTRY`, `INTENTION_CASCADE_PHASE` / `INTENTION_RUN_KIND` / `ROUTE_RESOLVED_VIA`
|
|
648
|
+
- `StudioEvent` / `StudioEventListener` / `StudioEventType` / `STUDIO_EVENTS` / `STUDIO_EVENT_LISTENERS`
|
|
649
|
+
- `STUDIO_CONVERSATION_LIMITS`, `STUDIO_BRAIN_CONFIG` / `StudioBrainConfig` / `resolveStudioBrainConfig`
|
|
650
|
+
- `ConversationBootstrapService`, `SupervisedConversation`, registries, `CallTurnQueue`
|
|
651
|
+
- `ConversationEntryPoint`, schema/history types
|
|
652
|
+
- `ProjectEntity`, `ProjectRepository`
|
|
653
|
+
- `BrainService` / adapters / `STANDARD_INTENTIONS` / `STUDIO_CLARIFY_CANNOT_ANSWER`
|
|
654
|
+
- `FlowLoader`, `WorkflowLoader`, `WorkflowHandoffService`
|
|
655
|
+
- `FormsService`, `FORM_DISPOSE_ADAPTER`, `TwilioSmsFormDisposeAdapter`, form types / timeout errors
|
|
656
|
+
- `StudioTaskQueue`, `createStudioTasksApi`, `ctx.tasks`, `STUDIO_TASK_EVENTS` / `STUDIO_EVENTS.TASK_*`
|
|
657
|
+
- `EventService` (incl. `persistAnalyticsTag`), daily-log helpers
|
|
658
|
+
- `ANALYTICS_TAG_EVENT`, `normalizeAnalyticsFunnels`, analytics funnel types (`AnalyticsFunnelDefinition`, `AnalyticsFunnelStep`, `AnalyticsTagPayload`)
|
|
659
|
+
- `IntegrationClient`, JWT helpers
|
|
660
|
+
- `VapiSseCompiler`, `extractVapiCallId`, `extractVapiCallerNumber`, `resolveHandoffToolName`, `buildVapiHandoffToolArgs`, `hasAdvertisedHandoffTool`
|
|
661
|
+
- Listen timeout: `DEFAULT_LISTEN_TIMEOUT_SECONDS`, `AgentNode.listenTimeoutSeconds`, `AgentNode.interruptible`, `listenTimeoutToVapiStartSpeakingPlan`
|
|
662
|
+
- Catch helpers: `restartNode`, `forceIntention`, `FlowUncertainError`
|
|
663
|
+
|
|
664
|
+
## Env the framework reads
|
|
665
|
+
|
|
666
|
+
| Variable | Default | Meaning |
|
|
667
|
+
| --- | --- | --- |
|
|
668
|
+
| `OPENAI_API_KEY` | — | ChatGPT Brain adapter secret |
|
|
669
|
+
| `ANTHROPIC_API_KEY` | — | Claude Brain adapter secret |
|
|
670
|
+
| `GOOGLE_API_KEY` | — | Gemini Brain adapter (or `GEMINI_API_KEY`) |
|
|
671
|
+
| `XAI_API_KEY` | — | Grok Brain adapter secret |
|
|
672
|
+
| `STUDIO_CONSOLE_DEBUG` | on | Pretty stdout |
|
|
673
|
+
| `STUDIO_FILE_LOG` | on | Daily files |
|
|
674
|
+
| `LOG_DIR` | `logs` | Directory the **app** owns (project `logs/`); framework writes `dailyYYYYMMDD.log` here |
|
|
675
|
+
| `LOG_DAYS` | `14` | Retention |
|
|
676
|
+
| `STUDIO_FORM_ACK_MS` | `15000` | Form deliver ACK window (tests may lower) |
|
|
677
|
+
| `VAPI_END_CALL_TOOL_NAME` | `end_call_tool` | End-call tool (must match the Vapi tool name) |
|
|
678
|
+
| `VAPI_TRANSFER_CALL_TOOL_NAME` | `transfer_call` | Transfer tool (`.env.example` snake_case; live may keep `transferCall`) |
|
|
679
|
+
| `VAPI_HANDOFF_TOOL_NAME` | `handoff` | Squad module handoff tool |
|
|
680
|
+
| `VAPI_TRANSFER_DESTINATION` | — | E.164 for transfer destination |
|
|
681
|
+
| `CONFIG_DIR` | app `config/` | Flow + `workflow.yaml` search path |
|
|
682
|
+
| `STUDIO_CONSOLE_DEBUG_ALL` | off | Verbose console (all events) |
|
|
683
|
+
|
|
684
|
+
Apps add their own env (database URL, public base URL, transfer destination). Document in the app’s `.env.example` and README.
|
|
685
|
+
|
|
686
|
+
## Best practices (for humans and agents)
|
|
687
|
+
|
|
688
|
+
Doctrine for conversation design — separate from this runtime handbook:
|
|
689
|
+
|
|
690
|
+
| Path | Role |
|
|
691
|
+
| --- | --- |
|
|
692
|
+
| [`docs/best-practices/`](../best-practices/) | Guides: one CTA, Nodes/listens, identity/PII, **debugging/observability**, doc layers |
|
|
693
|
+
| [`agent/AGENTS.md`](../../agent/AGENTS.md) | Canonical agent entry |
|
|
694
|
+
| `agent/cursor/vapi-studio-best-practices.mdc` | Copied into each app’s `.cursor/rules/` on install |
|
|
695
|
+
|
|
696
|
+
When doctrine changes, update the guides in the **same** change. When runtime contracts change, update **this document**.
|
|
697
|
+
|
|
698
|
+
## Tests
|
|
699
|
+
|
|
700
|
+
```bash
|
|
701
|
+
yarn test # tsc + node:test under test/*.test.mjs
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
Mocked unit tests; no live Vapi or paid Brain unless a future eval slice says so.
|
|
705
|
+
|
|
706
|
+
## Out of scope (current MVP)
|
|
707
|
+
|
|
708
|
+
Multi-process runtime registry / production failover, generic CLI app generator, production customer business logic in the framework, default-on ChatGPT, Vapi Evals replacement, automatic crash restore on boot, cross-call resume unless an app opts in.
|