assets/agent-remote-sandbox-github-repo-example.json
{
"agent_node_config": {
"node_type": "agent",
"config": {
"system_prompt": "Inspect the mounted repository before answering. Read the README and the most relevant files under /workspace/repos/acme-app, summarize the architecture, and then propose the smallest safe change.",
"provider_model_id": "uuid",
"max_iterations": 20,
"max_tokens": 8192,
"temperature": 0.2,
"sandbox_enabled": true,
"sandbox_network_mode": "allow_list",
"sandbox_allowed_outbound_hosts": [
"api.example.com"
],
"flow_agent_resources": [
{
"resource_type": "github_repository",
"repo_url": "https://github.com/acme/acme-app",
"branch": "main",
"pat": "github_pat_replace_me"
}
],
"flow_agent_webhooks": [],
"flow_agent_function_tools": [],
"flow_agent_mcp_servers": []
}
}
}
assets/function-decide-route-interactive-buttons.json
{
"name": "decide-route-interactive-buttons",
"description": "Decide node helper: route to the next edge based on vars.button_choice from an interactive button reply.",
"code": "async function handler(request, env) {\n const payload = await request.json();\n\n const executionContext = payload && payload.execution_context ? payload.execution_context : {};\n const vars = executionContext && executionContext.vars ? executionContext.vars : {};\n const availableEdges = Array.isArray(payload && payload.available_edges) ? payload.available_edges : [];\n\n const raw = vars.button_choice ?? vars.last_user_input;\n\n function asString(value) {\n if (typeof value === 'string') return value;\n if (typeof value === 'number') return String(value);\n if (value && typeof value === 'object') {\n try {\n return JSON.stringify(value);\n } catch {\n return null;\n }\n }\n return null;\n }\n\n function extractChoice(value) {\n if (!value) return null;\n\n if (typeof value === 'string') {\n const trimmed = value.trim();\n if (trimmed.length === 0) return null;\n return trimmed;\n }\n\n if (value && typeof value === 'object') {\n const v = value;\n const direct = v.button_id || v.buttonId || v.list_id || v.listId || v.id || v.choice || v.value;\n if (typeof direct === 'string' && direct.trim().length > 0) return direct.trim();\n\n // Common nested shapes.\n const nested =\n (v.interactive && (v.interactive.button_reply || v.interactive.list_reply)) ||\n v.button_reply ||\n v.list_reply;\n\n if (nested && typeof nested === 'object') {\n const nestedId = nested.id || nested.button_id || nested.list_id;\n if (typeof nestedId === 'string' && nestedId.trim().length > 0) return nestedId.trim();\n }\n }\n\n return null;\n }\n\n const extracted = extractChoice(raw);\n const normalized = extracted ? extracted.toLowerCase().trim() : null;\n\n // Map common variants to canonical edge labels.\n const mapped = normalized === 'sale' ? 'sales' : normalized;\n\n if (mapped && availableEdges.includes(mapped)) {\n return new Response(JSON.stringify({ next_edge: mapped }), {\n headers: { 'Content-Type': 'application/json' }\n });\n }\n\n const fallback = availableEdges[0] || 'next';\n const reason = {\n extracted: extracted,\n raw_preview: asString(raw),\n available_edges: availableEdges\n };\n\n return new Response(JSON.stringify({ next_edge: fallback, vars: { decision_reason: reason } }), {\n headers: { 'Content-Type': 'application/json' }\n });\n}\n"
}
assets/functions-example.json
{
"name": "webhook-handler",
"description": "Store webhook payloads",
"code": "async function handler(request, env) {\n const payload = await request.json();\n const key = `webhook:${Date.now()}`;\n await env.KV.put(key, JSON.stringify({ event: payload.event, data: payload.data }));\n return new Response(JSON.stringify({ success: true }), { headers: { 'Content-Type': 'application/json' } });\n}"
}
assets/workflow-agent-simple.json
{
"nodes": [
{
"id": "start",
"type": "flow-node",
"position": { "x": 120, "y": 120 },
"data": {
"node_type": "start",
"config": {}
}
},
{
"id": "agent_1710000100000",
"type": "flow-node",
"position": { "x": 120, "y": 320 },
"data": {
"node_type": "agent",
"config": {
"system_prompt": "You are a helpful assistant. Ask one clarifying question if needed, then call complete_task.",
"provider_model_id": "PROVIDER_MODEL_ID_HERE",
"max_iterations": 10,
"temperature": 0.2
}
}
}
],
"edges": [
{ "source": "start", "target": "agent_1710000100000", "label": "next" }
]
}
assets/workflow-api-template-wait-agent.json
{
"nodes": [
{
"id": "start",
"type": "flow-node",
"position": { "x": 120, "y": 120 },
"data": {
"node_type": "start",
"config": {}
}
},
{
"id": "send_template_1710000500000",
"type": "flow-node",
"position": { "x": 120, "y": 320 },
"data": {
"node_type": "send_template",
"config": {
"template_id": "TEMPLATE_ID_HERE",
"parameters": {
"1": "{{vars.customer_name}}",
"2": "{{vars.case_id}}"
}
}
}
},
{
"id": "wait_for_response_1710000504000",
"type": "flow-node",
"position": { "x": 120, "y": 520 },
"data": {
"node_type": "wait_for_response",
"config": {
"save_response_to": "user_reply"
}
}
},
{
"id": "agent_1710000508000",
"type": "flow-node",
"position": { "x": 120, "y": 720 },
"data": {
"node_type": "agent",
"config": {
"system_prompt": "You are following up on a WhatsApp template message triggered by an API call. Use vars.case_id and vars.customer_name for context. The user's reply is in vars.user_reply. Resolve the request or gather the missing details. When complete, summarize and call complete_task.",
"provider_model_id": "PROVIDER_MODEL_ID_HERE",
"max_iterations": 20,
"temperature": 0.2
}
}
}
],
"edges": [
{ "source": "start", "target": "send_template_1710000500000", "label": "next" },
{ "source": "send_template_1710000500000", "target": "wait_for_response_1710000504000", "label": "next" },
{ "source": "wait_for_response_1710000504000", "target": "agent_1710000508000", "label": "next" }
]
}
assets/workflow-customer-support-intake-agent.json
{
"nodes": [
{
"id": "start",
"type": "flow-node",
"position": { "x": 120, "y": 120 },
"data": {
"node_type": "start",
"config": {}
}
},
{
"id": "send_text_1710000200000",
"type": "flow-node",
"position": { "x": 120, "y": 320 },
"data": {
"node_type": "send_text",
"config": {
"message": "Thanks for reaching out. Please reply with your email and a short description of the issue.",
"delay_seconds": 0
}
}
},
{
"id": "wait_for_response_1710000204000",
"type": "flow-node",
"position": { "x": 120, "y": 520 },
"data": {
"node_type": "wait_for_response",
"config": {
"save_response_to": "support_intake"
}
}
},
{
"id": "agent_1710000208000",
"type": "flow-node",
"position": { "x": 120, "y": 720 },
"data": {
"node_type": "agent",
"config": {
"system_prompt": "You are a customer support agent. The user's intake message is in vars.support_intake. Extract the email and issue summary. Ask concise follow-up questions if critical details are missing. When you have enough info, summarize the issue and call complete_task.",
"provider_model_id": "PROVIDER_MODEL_ID_HERE",
"max_iterations": 20,
"temperature": 0.2
}
}
}
],
"edges": [
{ "source": "start", "target": "send_text_1710000200000", "label": "next" },
{ "source": "send_text_1710000200000", "target": "wait_for_response_1710000204000", "label": "next" },
{ "source": "wait_for_response_1710000204000", "target": "agent_1710000208000", "label": "next" }
]
}
assets/workflow-decision.json
{
"nodes": [
{
"id": "start",
"type": "flow-node",
"position": { "x": 120, "y": 120 },
"data": {
"node_type": "start",
"config": {}
}
},
{
"id": "send_text_1710000010000",
"type": "flow-node",
"position": { "x": 120, "y": 320 },
"data": {
"node_type": "send_text",
"config": {
"message": "Do you want to continue? Reply yes or no.",
"delay_seconds": 0
}
}
},
{
"id": "wait_for_response_1710000014000",
"type": "flow-node",
"position": { "x": 120, "y": 520 },
"data": {
"node_type": "wait_for_response",
"config": {
"save_response_to": "user_reply"
}
}
},
{
"id": "decide_1710000018000",
"type": "flow-node",
"position": { "x": 120, "y": 720 },
"data": {
"node_type": "decide",
"config": {
"decision_type": "function",
"function_id": "FUNCTION_ID_HERE",
"conditions": [
{ "label": "yes", "description": "User agreed" },
{ "label": "no", "description": "User declined" }
]
}
}
},
{
"id": "send_text_1710000022000",
"type": "flow-node",
"position": { "x": 0, "y": 920 },
"data": {
"node_type": "send_text",
"config": {
"message": "Great, let's continue!",
"delay_seconds": 0
}
}
},
{
"id": "send_text_1710000026000",
"type": "flow-node",
"position": { "x": 240, "y": 920 },
"data": {
"node_type": "send_text",
"config": {
"message": "No problem. Ending the workflow.",
"delay_seconds": 0
}
}
}
],
"edges": [
{ "source": "start", "target": "send_text_1710000010000", "label": "next" },
{ "source": "send_text_1710000010000", "target": "wait_for_response_1710000014000", "label": "next" },
{ "source": "wait_for_response_1710000014000", "target": "decide_1710000018000", "label": "next" },
{ "source": "decide_1710000018000", "target": "send_text_1710000022000", "label": "yes" },
{ "source": "decide_1710000018000", "target": "send_text_1710000026000", "label": "no" }
]
}
assets/workflow-interactive-buttons-decide-ai.json
{
"nodes": [
{
"id": "start",
"type": "flow-node",
"position": { "x": 120, "y": 120 },
"data": {
"node_type": "start",
"config": {}
}
},
{
"id": "send_interactive_1710000400000",
"type": "flow-node",
"position": { "x": 120, "y": 320 },
"data": {
"node_type": "send_interactive",
"config": {
"interactive_type": "button",
"body_text": "How can we help you today?",
"buttons": [
{ "id": "sales", "title": "Sales" },
{ "id": "support", "title": "Support" }
]
}
}
},
{
"id": "wait_for_response_1710000404000",
"type": "flow-node",
"position": { "x": 120, "y": 520 },
"data": {
"node_type": "wait_for_response",
"config": {
"save_response_to": "button_choice"
}
}
},
{
"id": "decide_1710000408000",
"type": "flow-node",
"position": { "x": 120, "y": 720 },
"data": {
"node_type": "decide",
"config": {
"decision_type": "ai",
"provider_model_id": "PROVIDER_MODEL_ID_HERE",
"conditions": [
{ "label": "sales", "description": "User selected Sales (button id: sales)" },
{ "label": "support", "description": "User selected Support (button id: support)" }
],
"llm_temperature": 0.0
}
}
},
{
"id": "send_text_1710000412000",
"type": "flow-node",
"position": { "x": 0, "y": 920 },
"data": {
"node_type": "send_text",
"config": {
"message": "Got it - connecting you to Sales.",
"delay_seconds": 0
}
}
},
{
"id": "send_text_1710000416000",
"type": "flow-node",
"position": { "x": 240, "y": 920 },
"data": {
"node_type": "send_text",
"config": {
"message": "Got it - connecting you to Support.",
"delay_seconds": 0
}
}
}
],
"edges": [
{ "source": "start", "target": "send_interactive_1710000400000", "label": "next" },
{ "source": "send_interactive_1710000400000", "target": "wait_for_response_1710000404000", "label": "next" },
{ "source": "wait_for_response_1710000404000", "target": "decide_1710000408000", "label": "next" },
{ "source": "decide_1710000408000", "target": "send_text_1710000412000", "label": "sales" },
{ "source": "decide_1710000408000", "target": "send_text_1710000416000", "label": "support" }
]
}
assets/workflow-interactive-buttons-decide-function.json
{
"nodes": [
{
"id": "start",
"type": "flow-node",
"position": { "x": 120, "y": 120 },
"data": {
"node_type": "start",
"config": {}
}
},
{
"id": "send_interactive_1710000300000",
"type": "flow-node",
"position": { "x": 120, "y": 320 },
"data": {
"node_type": "send_interactive",
"config": {
"interactive_type": "button",
"body_text": "How can we help you today?",
"buttons": [
{ "id": "sales", "title": "Sales" },
{ "id": "support", "title": "Support" }
]
}
}
},
{
"id": "wait_for_response_1710000304000",
"type": "flow-node",
"position": { "x": 120, "y": 520 },
"data": {
"node_type": "wait_for_response",
"config": {
"save_response_to": "button_choice"
}
}
},
{
"id": "decide_1710000308000",
"type": "flow-node",
"position": { "x": 120, "y": 720 },
"data": {
"node_type": "decide",
"config": {
"decision_type": "function",
"function_id": "FUNCTION_ID_HERE",
"conditions": [
{ "label": "sales", "description": "User selected Sales" },
{ "label": "support", "description": "User selected Support" }
]
}
}
},
{
"id": "send_text_1710000312000",
"type": "flow-node",
"position": { "x": 0, "y": 920 },
"data": {
"node_type": "send_text",
"config": {
"message": "Got it - connecting you to Sales.",
"delay_seconds": 0
}
}
},
{
"id": "send_text_1710000316000",
"type": "flow-node",
"position": { "x": 240, "y": 920 },
"data": {
"node_type": "send_text",
"config": {
"message": "Got it - connecting you to Support.",
"delay_seconds": 0
}
}
}
],
"edges": [
{ "source": "start", "target": "send_interactive_1710000300000", "label": "next" },
{ "source": "send_interactive_1710000300000", "target": "wait_for_response_1710000304000", "label": "next" },
{ "source": "wait_for_response_1710000304000", "target": "decide_1710000308000", "label": "next" },
{ "source": "decide_1710000308000", "target": "send_text_1710000312000", "label": "sales" },
{ "source": "decide_1710000308000", "target": "send_text_1710000316000", "label": "support" }
]
}
assets/workflow-linear.json
{
"nodes": [
{
"id": "start",
"type": "flow-node",
"position": { "x": 120, "y": 120 },
"data": {
"node_type": "start",
"config": {}
}
},
{
"id": "send_text_1710000000000",
"type": "flow-node",
"position": { "x": 120, "y": 320 },
"data": {
"node_type": "send_text",
"config": {
"message": "Hello! What's your name?",
"delay_seconds": 0
}
}
},
{
"id": "wait_for_response_1710000005000",
"type": "flow-node",
"position": { "x": 120, "y": 520 },
"data": {
"node_type": "wait_for_response",
"config": {
"save_response_to": "user_name"
}
}
},
{
"id": "send_text_1710000009000",
"type": "flow-node",
"position": { "x": 120, "y": 720 },
"data": {
"node_type": "send_text",
"config": {
"message": "Nice to meet you, {{vars.user_name}}!",
"delay_seconds": 0
}
}
}
],
"edges": [
{ "source": "start", "target": "send_text_1710000000000", "label": "next" },
{ "source": "send_text_1710000000000", "target": "wait_for_response_1710000005000", "label": "next" },
{ "source": "wait_for_response_1710000005000", "target": "send_text_1710000009000", "label": "next" }
]
}
package.json
{
"private": true,
"type": "module",
"dependencies": {
"yaml": "^2.6.0"
},
"scripts": {
"openapi": "node scripts/openapi-explore.mjs"
}
}
references/agent-remote-sandbox.md
# Agent Remote Sandbox
Use the remote sandbox when an agent node needs a disposable workspace to inspect or modify repository files during execution.
## What sandbox access changes
When `sandbox_enabled` is `true`, the agent gets a remote ephemeral workspace plus sandbox file tools:
- `bash`
- `read`
- `list_dir`
- `write`
- `edit`
Repository resources are separate from tool definitions. They are mounted into the sandbox only when sandbox access is enabled.
If sandbox access is later turned off, the repository resources stay saved in the node config. They are simply not mounted until sandbox access is enabled again.
Remote sandbox is in beta. Sandbox usage is free during the beta. Pricing may change later.
## GitHub repository resources
v1 supports GitHub repositories only.
Each repository entry in `flow_agent_resources` should include:
```json
{
"resource_type": "github_repository",
"repo_url": "https://github.com/org/repo",
"branch": "main",
"pat": "github_pat_replace_me"
}
```
Rules:
- Use a repository root URL only
- Valid examples: `https://github.com/org/repo`, `https://github.com/org/repo.git`, `git@github.com:org/repo.git`
- Do not use GitHub file URLs, subdirectory URLs, or `tree/...` URLs
- Each repository needs a GitHub Personal Access Token (PAT)
- Saved responses do not return the PAT; they only return metadata like `has_pat: true`
## Mounted paths inside the sandbox
Configured repositories are cloned into:
```text
/workspace/repos/<repo-slug>
```
Write the agent prompt so it refers to those mounted paths explicitly. For example:
- inspect `/workspace/repos/acme-app` before answering
- read the README and relevant service files before proposing changes
- make changes only inside the mounted repository unless the workflow explicitly needs something else
## Sandbox network policy
Use `sandbox_network_mode` to control outbound access from the remote sandbox:
- `allow_all`: permit all outbound hosts
- `allow_list`: permit only allow-listed hosts
When GitHub repositories are attached, Kapso automatically adds the GitHub hosts needed for cloning and repository access. Add entries to `sandbox_allowed_outbound_hosts` only for extra services your workflow needs, such as internal APIs or external documentation hosts.
## Recommended setup flow
1. Pick a model with `node scripts/list-provider-models.js`
2. Start from `assets/agent-remote-sandbox-github-repo-example.json`
3. Enable `sandbox_enabled`
4. Add one or more GitHub repository resources
5. Choose `sandbox_network_mode`
6. If using `allow_list`, add only the extra hosts the agent truly needs
7. Validate with `node scripts/validate-graph.js --definition-file <path>`
8. Update the workflow graph
references/execution-context.md
# Execution Context and Variables
Execution context shape:
```json
{
"vars": { "user_name": "Alice" },
"system": { "flow_id": "uuid", "flow_name": "...", "trigger_type": "inbound_message" },
"context": { "channel": "whatsapp", "phone_number": "+1234567890", "contact": { "wa_id": "...", "profile_name": "..." } },
"metadata": { "request": { "ip": "...", "timestamp": "..." } }
}
```
Always use this structure:
- `vars`: user-defined variables
- `system`: system variables (flow_id, trigger_type, etc)
- `context`: channel info (phone number, contact)
- `metadata`: request metadata
## Variable syntax
Environment variables (secrets):
- `${ENV:VARIABLE_NAME}`
Runtime variables:
- `{{vars.my_variable}}`
- `{{system.flow_name}}`
- `{{context.phone_number}}`
Substitution order:
1. Environment variables
2. Runtime variables
Important WhatsApp keys:
- `{{system.whatsapp_config.phone_number_id}}` (Meta phone_number_id)
- `{{system.trigger_whatsapp_config_id}}` (Kapso WhatsApp config id)
- `{{context.phone_number}}` (recipient phone)
Never guess variable paths. Use:
- `scripts/variables-list.js <workflow-id>`
- `scripts/get-execution.js <execution-id>`
- `scripts/get-context-value.js <execution-id> --variable-path <path>`
references/function-contracts.md
# Workflow Function Contracts
Use this when configuring function or decide nodes.
## Code rules (must follow)
- Code MUST start with: `async function handler(request, env) {`
- Do NOT use `export`, `export default`, or arrow functions.
- Output only JavaScript source code (no markdown fences).
## Payload (function/decide nodes)
```json
{
"execution_context": { "vars": {}, "system": {}, "context": {}, "metadata": {} },
"available_edges": ["edge_a", "edge_b"],
"flow_events": [{ "event_type": "...", "payload": {} }]
}
```
## Function node response
Return variables to merge:
```json
{ "vars": { "processed": true } }
```
## Decide node response
Return `next_edge` matching an outgoing edge label:
```json
{ "next_edge": "qualified", "vars": { "decision_reason": "qualified" } }
```
Always fall back to the first available edge if unsure.
## Agent Function Tool
Agent node function tools receive:
```json
{
"input": { "any": "shape" },
"execution_context": { "vars": {}, "system": {}, "context": {}, "metadata": {} },
"flow_events": [],
"flow_info": { "id": "uuid", "name": "...", "step_id": "uuid" },
"whatsapp_context": { "conversation": {}, "messages": [] }
}
```
For full runtime contract details, load:
- `functions-reference.md`
- `functions-payloads.md`
references/functions-payloads.md
# Payload Shapes (Quick Reference)
## Workflow function node (request)
Kapso sends:
```json
{
"execution_context": {
"vars": { "user_name": "John", "score": 42 },
"system": { "flow_id": "uuid", "flow_name": "...", "trigger_type": "inbound_message" },
"context": { "channel": "whatsapp", "phone_number": "+1234567890", "contact": { "wa_id": "...", "profile_name": "..." } },
"metadata": { "request": { "ip": "...", "timestamp": "..." } }
},
"available_edges": ["edge_a", "edge_b"],
"flow_events": [{ "event_type": "...", "payload": { "...": "..." } }]
}
```
### Function node response
Return vars to merge:
```json
{ "vars": { "processed": true, "result": "ok" } }
```
## Workflow decide node (request)
Same request shape as function nodes.
### Decide node response
Return `next_edge` that matches an outgoing edge label:
```json
{ "next_edge": "qualified", "vars": { "decision_reason": "qualified" } }
```
Always fall back to the first available edge if unsure.
## Agent function tool (request)
```json
{
"input": { "any": "shape" },
"execution_context": { "vars": {}, "system": {}, "context": {}, "metadata": {} },
"flow_events": [{ "event_type": "...", "payload": { "...": "..." } }],
"flow_info": { "id": "uuid", "name": "...", "step_id": "uuid" },
"whatsapp_context": { "conversation": { "...": "..." }, "messages": [] }
}
```
## WhatsApp Flow data endpoint (request)
Kapso forwards:
```json
{
"source": "whatsapp_flow",
"flow": { "id": "<uuid>", "meta_flow_id": "<meta_id>" },
"data_exchange": {
"action": "INIT" | "data_exchange" | "BACK",
"screen": "CURRENT_SCREEN_ID",
"data": { "...": "..." },
"flow_token": "opaque-token"
},
"signature_valid": true,
"received_at": "2024-01-01T00:00:00Z"
}
```
### WhatsApp Flow response
```json
{
"version": "3.0",
"screen": "NEXT_SCREEN_ID",
"data": {}
}
```
## Code rules
- Code MUST start with: `async function handler(request, env) {`
- Do NOT use `export`, `export default`, or arrow functions.
- Output only JavaScript source code (no markdown fences).
references/functions-reference.md
# Function Runtime Contract
## Handler signature
Functions must start with:
```
async function handler(request, env) {
```
Do not use `export` or arrow functions. Return a `Response` object.
## Runtime APIs
- `request`: Fetch API Request; use `await request.json()` for JSON.
- `env.KV`: KV storage with `.get(key)`, `.put(key, value)`, `.delete(key)`.
- `env.SECRET_NAME`: Secrets configured in the function settings.
## Typical workflow
1. Create function with `code` that follows the contract.
2. Deploy the function (required before use).
3. Read `endpoint_url` from the function record after deploy.
4. If the function should accept anonymous callers, set `public_endpoint: true` when creating or updating it. This is only supported for Cloudflare functions.
5. New functions default to `invoke_response_mode: "passthrough"`, which returns the function response body directly on successful invoke. Legacy wrapped functions can later be updated to `passthrough` once callers are ready.
## Platform API payload envelope
When calling the Platform API directly (not via scripts), wrap attributes under `function`:
```json
{
"function": {
"name": "...",
"description": "...",
"code": "...",
"invoke_response_mode": "passthrough",
"public_endpoint": false
}
}
```
Notes:
- `endpoint_url` for deployed Cloudflare functions is `https://api.kapso.ai/platform/v1/functions/{function_id}/invoke`
- Private functions require `X-API-Key`
- Public Cloudflare functions (`public_endpoint=true`) can be invoked without an API key
- `invoke_response_mode=passthrough` forwards the successful function response body directly
- `invoke_response_mode=wrapped` is a legacy mode for older wrapped functions
## Workflow node payload (Function / Decide)
When a workflow function node runs, Kapso sends:
```json
{
"execution_context": {
"vars": { "user_name": "John", "score": 42 },
"system": { "flow_id": "uuid", "flow_name": "...", "trigger_type": "inbound_message" },
"context": { "channel": "whatsapp", "phone_number": "+1234567890", "contact": { "wa_id": "...", "profile_name": "..." } },
"metadata": { "request": { "ip": "...", "timestamp": "..." } }
},
"available_edges": ["edge_a", "edge_b"],
"flow_events": [{ "event_type": "...", "payload": { "..." : "..." } }]
}
```
Execution context structure is always:
- `vars`: user-defined variables
- `system`: system variables (flow_id, trigger_type, etc)
- `context`: channel data (phone number, contact)
- `metadata`: request metadata
### Function node response
Return `vars` to update context:
```json
{
"vars": { "processed": true, "result": "ok" }
}
```
Function nodes can also emit Project Events by returning `project_events` alongside `vars`:
```json
{
"vars": { "csat_score": 5 },
"project_events": [
{
"name": "conversation.csat_scored",
"properties": { "score": 5, "source": "workflow" }
}
]
}
```
Project Event names must be lowercase dotted snake_case. Properties must be a flat object with scalar values only. A Function node response can emit at most 5 Project Events. Workflow test mode validates and logs emitted events without persisting them. Project-event-triggered workflows cannot emit Project Events.
### Decide node response
Return `next_edge` that matches an outgoing edge label:
```json
{
"next_edge": "qualified",
"vars": { "decision_reason": "qualified" }
}
```
Always fall back to the first available edge when unsure.
## Agent function tool payload
If an agent node uses a Function Tool, Kapso calls the function with:
```json
{
"input": { "any": "shape" },
"execution_context": { "vars": {}, "system": {}, "context": {}, "metadata": {} },
"flow_events": [{ "event_type": "...", "payload": { "...": "..." } }],
"flow_info": { "id": "uuid", "name": "...", "step_id": "uuid" },
"whatsapp_context": { "conversation": { "...": "..." }, "messages": [] }
}
```
The tool expects a standard JSON response; you may return `vars` as above.
## WhatsApp Flow data endpoint payload
For WhatsApp Flow data endpoints, Kapso forwards:
```json
{
"source": "whatsapp_flow",
"flow": { "id": "<uuid>", "meta_flow_id": "<meta_id>" },
"data_exchange": {
"action": "INIT" | "data_exchange" | "BACK",
"screen": "CURRENT_SCREEN_ID",
"data": { "...": "..." },
"flow_token": "opaque-token"
},
"signature_valid": true,
"received_at": "2024-01-01T00:00:00Z"
}
```
Respond with:
```json
{
"version": "3.0",
"screen": "NEXT_SCREEN_ID",
"data": {}
}
```
For full Flow JSON details and gotchas, load:
- `../integrate-whatsapp/references/whatsapp-flows-spec.md`
## Best practices
- Guard access: `const vars = body.execution_context?.vars || {};`
- Do not mutate `execution_context` in place; return new `vars`.
- Use try/catch for external API calls.
- Keep responses under the Meta timeout (10-15s).
references/graph-contract.md
# Workflow Graph Contract (Workflows / Definition)
This document is the source of truth for editing Kapso workflow graphs over the Platform API.
## Endpoints and envelopes
- Fetch graph (definition): `GET /platform/v1/workflows/:id/definition`
- Returns a workflow record that includes `definition` (nodes + edges).
- Fetch metadata: `GET /platform/v1/workflows/:id`
- Returns workflow metadata (including `lock_version`) but does NOT include `definition`.
- Update graph/settings: `PATCH /platform/v1/workflows/:id`
- Send `workflow: { ... }` (what the scripts use). `flow: { ... }` is accepted as an alias.
- To update the graph, send `workflow: { definition: <definition> }`.
## Two graph shapes: returned vs editable
### Shape returned by get-graph (ReactFlow-style)
`GET /workflows/:id/definition` returns a ReactFlow-style definition that includes extra/computed fields:
```json
{
"nodes": [
{
"id": "start",
"type": "flow-node",
"position": { "x": 120, "y": 120 },
"data": { "node_type": "start", "config": {}, "display_name": "Start" }
}
],
"edges": [
{
"id": "uuid",
"source": "start",
"target": "send_text_1710000000000",
"label": "next",
"type": "default",
"flow_condition_id": null
}
]
}
```
### Minimal editable shape accepted by the API
For `PATCH /workflows/:id`, the minimal shape you should edit and send is:
```json
{
"nodes": [
{
"id": "start",
"position": { "x": 120, "y": 120 },
"data": { "node_type": "start", "config": {} }
},
{
"id": "send_text_1710000000000",
"position": { "x": 120, "y": 320 },
"data": {
"node_type": "send_text",
"config": { "message": "Hello!", "delay_seconds": 0 }
}
}
],
"edges": [
{ "source": "start", "target": "send_text_1710000000000", "label": "next" }
]
}
```
The API ignores/strips extra fields like `node.type`, `data.display_name`, `edge.id`, and `edge.type`. You can keep them unchanged when roundtripping, but do not rely on editing them.
## Nodes
Required:
- `node.id` (string)
- `node.position.x`, `node.position.y` (numbers)
- `node.data.node_type` (string)
- `node.data.config` (object; per node_type)
Rules:
- Exactly one start node with `id = "start"` and `data.node_type = "start"`.
- Never change existing node IDs.
- For new nodes: use `{node_type}_{timestamp_ms}` for the `id`.
Footgun (important):
- If `data.node_type` is missing, the backend defaults it to `"start"`.
- If you create a node with an unknown `data.node_type`, the backend will create it as a start-like step.
- Always validate before updating: `node scripts/validate-graph.js --definition-file <path>` and treat warnings as blockers.
## Edges
Required:
- `edge.source` (existing node id)
- `edge.target` (existing node id)
- `edge.label` (string)
Rules:
- Non-decide nodes: 0 or 1 outgoing edge; if present, its `label` must be `"next"`.
- Decide nodes: one outgoing edge per condition; each edge `label` must match `config.conditions[].label`.
Optional:
- `edge.flow_condition_id` (only meaningful for decide edges). If present, it must refer to a condition on that decide node.
## Decide node conditions (ids)
For `decide` nodes, `config.conditions[]` controls valid outgoing edge labels.
- When creating NEW conditions, do not include an `id` field (the backend generates it).
- When editing an EXISTING decide node fetched from the API, you may see `conditions[].id` and `edges[].flow_condition_id` in the returned graph. Keep them unchanged unless you have a specific reason to remove/rebuild the decide node.
## Computed vs editable fields
Do edit:
- `node.position`
- `node.data.node_type`
- `node.data.config`
- `edge.source`, `edge.target`, `edge.label`
Do NOT treat as editable (computed/ignored/unstable):
- `node.data.display_name`
- `edge.id`, `edge.type`
- any `*_name` fields (model names, function names, etc.)
## Terminal nodes and agent nodes
- Terminal nodes (leaf nodes) are allowed. A node with no outgoing edge ends the workflow after that step completes.
- Agent nodes can be terminal or can continue via a `"next"` edge, depending on what you want:
- Terminal agent: the agent handles the conversation and finishes via its tools (ex: complete_task or handoff_to_human).
- Continuing agent: add a `"next"` edge if you want deterministic post-agent steps (ex: send_text summary).
## lock_version conflicts (exact retry pattern)
The Platform API does not currently enforce `lock_version` for definition updates, so the scripts do a precheck.
Use this pattern:
1. Fetch graph and lock_version: `node scripts/get-graph.js <workflow_id>`
2. Apply change with lock precheck:
- Small surgical edit: `node scripts/edit-graph.js <workflow_id> --expected-lock-version <n> ...`
- Full update: `node scripts/update-graph.js <workflow_id> --expected-lock-version <n> --definition-file <path>`
- `update-graph.js` can extract `definition` from wrapper JSON, but prefer sending just the `definition` object.
3. If you get a conflict error:
- Re-fetch the latest graph to get the new lock_version.
- Re-apply your change on top of the latest definition.
- Retry with the new expected lock version.
references/local-workflow-source.md
# Local Workflow Source
Use the Kapso CLI source-sync workflow when the user wants workflows and functions in a local repo.
## Setup
```bash
npm install -g @kapso/cli
npm install --save-dev @kapso/workflows
kapso login
kapso link --project <project-id>
kapso pull
```
`kapso link` binds the current directory to one Kapso project. `kapso pull` writes the local source tree.
```text
kapso.yaml
.kapso/project.json
.kapso/remote-map.json
functions/<function-slug>/function.yaml
functions/<function-slug>/index.js
workflows/<workflow-slug>/workflow.yaml
workflows/<workflow-slug>/definition.json
workflows/<workflow-slug>/workflow.js
```
Commit `kapso.yaml`, `.kapso/project.json`, `.kapso/remote-map.json`, `functions/`, and `workflows/` when the repo is shared. The remote map stores the last pulled remote state for stale-update and dirty-file checks.
## Workflow code
`kapso pull` creates `workflow.js` next to `definition.json` when no workflow source file exists. Edit `workflow.js`, or create `workflow.ts`, when code should be the source of truth.
```ts
import { START, Workflow } from "@kapso/workflows";
const workflow = new Workflow("support-router", {
name: "Support Router",
status: "draft",
});
workflow.addTrigger({
type: "inbound_message",
phoneNumberId: "<phone-number-id>",
});
workflow.addNode(START, {
position: { x: 100, y: 100 },
});
workflow.addNode("reply", {
type: "send_text",
message: "Thanks for reaching out.",
});
workflow.addEdge(START, "reply");
export default workflow;
```
Use `rawConfig` for advanced node configuration. Use slugs for local references when possible: `functionSlug` for functions and `workflowSlug` for called workflows.
Project Event triggers and `emit_event` nodes are supported in local source files. Use this source shape when a workflow needs event triggers or event emission.
In `workflow.yaml`, add the trigger:
```yaml
triggers:
- triggerType: project_event
triggerableAttributes:
event_name: conversation.csat_scored
property_key: score
operator: gte
property_value: 4
```
In `workflow.js` or `workflow.ts`, add the node with `@kapso/workflows`:
```ts
workflow.addNode("record_score", {
type: "emit_event",
eventName: "conversation.csat_scored",
properties: { score: "{{vars.score}}", source: "workflow" },
});
```
## Build and push
```bash
kapso build
kapso push --dry-run
kapso push workflow <workflow-slug>
```
Use `kapso push` to push every changed local function and workflow. Use `kapso push function <function-slug>` for one function.
If a workflow source file exists and changed, `kapso push` compiles it before uploading. If no workflow code file exists, the CLI uses `workflow.yaml` and `definition.json` directly, so JSON-only editing still works.
## Pull behavior
- `kapso pull` preserves hand-authored `workflow.js` and `workflow.ts` files.
- `kapso pull` updates remote-owned `workflow.yaml` and `definition.json`.
- `kapso pull` refuses to overwrite dirty remote-owned files.
- `kapso pull --diff` shows blocked incoming diffs.
- `kapso pull --overwrite` replaces local remote-owned files with remote versions.
When code is the source of truth, committing generated `workflow.yaml` and `definition.json` is a repo decision. Commit them when reviewable generated diffs are useful; ignore them when the team wants only authored workflow code in git.
## When to use API scripts instead
Use `scripts/get-graph.js`, `scripts/update-graph.js`, and related Platform API scripts only when:
- the user cannot use the local CLI source-sync repo,
- you need direct graph inspection for debugging,
- a task is not yet supported by `kapso pull/build/push`,
- or you need one-off API operations such as execution inspection.
references/node-types.md
# Workflow Node Types
## Supported node_type values
These are the `data.node_type` values supported by the Platform API and validated by `scripts/validate-graph.js`:
- `start`
- `send_text`
- `send_template`
- `send_interactive`
- `wait_for_response`
- `set_variable`
- `decide`
- `call`
- `webhook`
- `function`
- `agent`
- `handoff`
- `emit_event`
Messaging nodes are `send_text`, `send_template`, and `send_interactive`.
Node structure:
```json
{
"id": "send_text_1710000000000",
"type": "flow-node",
"position": { "x": 300, "y": 100 },
"data": {
"node_type": "send_text",
"config": {},
"display_name": "Send Text"
}
}
```
Notes:
- `display_name` is computed by the backend and is not reliably editable.
- New node IDs should use `{node_type}_{timestamp_ms}`.
- Nodes connect from bottom to top; organize the graph vertically (top-down) rather than left-to-right.
## start
```json
{ "node_type": "start", "config": {} }
```
Exactly one start node per workflow, id must be `start`.
## send_text
```json
{ "node_type": "send_text", "config": { "message": "Hello {{vars.name}}!", "delay_seconds": 0 } }
```
Optional: `whatsapp_config_id`, `to_phone_number`.
## send_template
```json
{
"node_type": "send_template",
"config": {
"template_id": "uuid",
"parameters": { "1": "{{vars.name}}" }
}
}
```
Optional: `whatsapp_config_id`, `to_phone_number`.
Use the `integrate-whatsapp` skill to find template IDs and parameter formats.
## send_interactive (buttons)
```json
{
"node_type": "send_interactive",
"config": {
"interactive_type": "button",
"body_text": "Choose an option:",
"buttons": [
{ "id": "yes", "title": "Yes" },
{ "id": "no", "title": "No" }
]
}
}
```
Max 3 buttons; id + title required (title max 20 chars).
## send_interactive (list)
```json
{
"node_type": "send_interactive",
"config": {
"interactive_type": "list",
"body_text": "Select:",
"list_button_text": "View options",
"list_sections": [
{ "title": "Section", "rows": [{ "id": "opt1", "title": "Option 1" }] }
]
}
}
```
## send_interactive (cta_url)
```json
{
"node_type": "send_interactive",
"config": {
"interactive_type": "cta_url",
"body_text": "Visit our site",
"cta_display_text": "Open",
"cta_url": "https://example.com"
}
}
```
## send_interactive (flow)
```json
{
"node_type": "send_interactive",
"config": {
"interactive_type": "flow",
"body_text": "Open calendar",
"flow_id": "<META_FLOW_ID>",
"flow_cta": "Open",
"flow_action": "navigate",
"flow_action_payload": { "screen": "FIRST_SCREEN" }
}
}
```
Notes:
- `flow_id` is the Meta Flow ID (string).
- `flow_action_payload.screen` must be a valid first screen.
## wait_for_response
```json
{ "node_type": "wait_for_response", "config": { "save_response_to": "user_reply" } }
```
Saves the next message into `vars.user_reply`.
## set_variable
```json
{
"node_type": "set_variable",
"config": {
"variable_name": "customer_name",
"variable_value": "{{vars.user_reply}}",
"value_type": "string"
}
}
```
Notes:
- Use `value_type: "string"` unless you have a specific reason to store a different type.
## decide (AI routing)
```json
{
"node_type": "decide",
"config": {
"decision_type": "ai",
"provider_model_id": "uuid",
"conditions": [
{ "label": "interested", "description": "User shows interest" },
{ "label": "not_interested", "description": "User declines" }
]
}
}
```
## decide (decision model routing)
```json
{
"node_type": "decide",
"config": {
"decision_type": "decision_model",
"conditions": [
{ "label": "interested", "description": "User shows interest" },
{ "label": "not_interested", "description": "User declines" }
]
}
}
```
Notes:
- This mode currently uses TypeSafe's Jev choice model. Do not send `provider_model_id`, `llm_temperature`, or `llm_max_tokens`.
- Jev supports at most 255 conditions per node.
## decide (function routing)
```json
{
"node_type": "decide",
"config": {
"decision_type": "function",
"function_id": "uuid",
"conditions": [
{ "label": "yes", "description": "Approved" },
{ "label": "no", "description": "Rejected" }
]
}
}
```
Rules:
- Outgoing edge labels must match `conditions[].label`.
- When creating new conditions, do not include an `id` field.
## call (subworkflow)
```json
{ "node_type": "call", "config": { "workflow_id": "uuid", "save_error_to": "subflow_error" } }
```
## webhook
```json
{
"node_type": "webhook",
"config": {
"url": "https://api.example.com/endpoint",
"method": "POST",
"headers": { "Authorization": "Bearer {{vars.token}}" },
"body_template": { "phone": "{{context.phone_number}}" },
"save_response_to": "api_result"
}
}
```
`headers` and `body_template` must be valid JSON objects.
## function
```json
{ "node_type": "function", "config": { "function_id": "uuid", "save_response_to": "fn_result" } }
```
Use `automate-whatsapp` function scripts to find function IDs and update code.
## emit_event
```json
{
"node_type": "emit_event",
"config": {
"event_name": "conversation.csat_scored",
"properties": {
"score": "{{vars.score}}",
"source": "workflow"
},
"occurred_at": "{{vars.scored_at}}"
}
}
```
Records a Project Event from the workflow execution. `properties` must be a flat JSON object with scalar values only. `occurred_at` is optional; omit it to use current time.
Limits:
- A workflow execution can emit at most 10 Project Events total.
- A workflow execution can emit at most 3 events with the same name.
- Project-event-triggered workflows cannot emit Project Events.
## agent
```json
{
"node_type": "agent",
"config": {
"system_prompt": "You are a helpful assistant...",
"provider_model_id": "uuid",
"max_iterations": 10,
"temperature": 0.7,
"message_delivery_mode": "auto_send_assistant_text"
}
}
```
Notes:
- `provider_model_id` is required. Use `scripts/list-provider-models.js` to find it.
- Agent tool arrays live inside `data.config` (not at the `data` root).
- `message_delivery_mode` controls user-visible text:
- `auto_send_assistant_text` sends normal assistant text automatically.
- `tool_only` keeps normal assistant text internal; the agent must call `send_notification_to_user` for every user-visible message.
- With `tool_only`, include `send_notification_to_user` and `enter_waiting` in `enabled_default_tools`. Use `enter_waiting` after questions that need a reply.
<!-- TODO: Add a full tool-only agent workflow asset after the API behavior is generally available. -->
Default tools (toggle on/off only):
- complete_task (required)
- handoff_to_human (required)
- enter_waiting
- send_notification_to_user
- send_media
- get_execution_metadata
- get_whatsapp_context
- contact_conversations
- get_current_datetime
- save_variable
- get_variable
- emit_event (default_enabled: false)
- ask_about_file
Enable `emit_event` only when the agent should decide whether or when to record a Project Event. Configure the tool with allowed event definitions; the agent can only emit those event names. Prefer a deterministic `emit_event` node when the workflow should always record the event at a known step.
Use `contact_conversations` when an agent should list and read older WhatsApp conversations for the same current contact. Call it with `action: "list"` first, then call `action: "read"` with a returned `conversation_id`.
Custom tools:
- `flow_agent_webhooks[]` (webhook tools)
- `flow_agent_mcp_servers[]` (MCP tools)
### Agent tools: exact placement and structure
All tool arrays go under `data.config` of the agent node:
```json
{
"id": "agent_1730000000000",
"type": "flow-node",
"position": { "x": 120, "y": 320 },
"data": {
"node_type": "agent",
"config": {
"system_prompt": "You can schedule appointments and use calendar tools.",
"provider_model_id": "uuid",
"max_iterations": 10,
"temperature": 0.7,
"message_delivery_mode": "tool_only",
"enabled_default_tools": [
"complete_task",
"handoff_to_human",
"enter_waiting",
"send_notification_to_user",
"get_whatsapp_context",
"emit_event"
],
"default_tool_configs": {
"emit_event": {
"event_definition_ids": ["uuid"]
}
},
"flow_agent_webhooks": [],
"flow_agent_mcp_servers": []
}
}
}
```
#### Webhook tools (custom HTTP tools)
Use when the agent needs to call arbitrary HTTP endpoints:
```json
{
"flow_agent_webhooks": [
{
"name": "lookup_customer",
"description": "Fetch customer data from internal API",
"url": "https://api.example.com/customers/lookup",
"http_method": "POST",
"headers": {
"Authorization": "Bearer {{vars.api_token}}",
"Content-Type": "application/json"
},
"body": {
"phone_number": "{{context.phone_number}}"
},
"body_schema": {
"type": "object",
"properties": {
"phone_number": { "type": "string" }
},
"required": ["phone_number"]
},
"jmespath_query": null
}
]
}
```
Rules:
- `body_schema` must be valid JSON Schema.
- `headers` and `body` must be JSON objects (not strings).
- Tool inputs are defined by `body_schema` and are sent as the webhook JSON body.
#### MCP server tools
Use to attach MCP servers the agent can call:
```json
{
"flow_agent_mcp_servers": [
{
"name": "files",
"description": "Local file access",
"url": "https://mcp.example.com",
"headers": {
"Authorization": "Bearer {{vars.mcp_token}}"
}
}
]
}
```
Rules:
- `url` is required.
- `headers` must be a JSON object.
- MCP tool inputs are defined by the MCP server's tool schemas (not configured here).
## handoff
```json
{ "node_type": "handoff", "config": {} }
```
Ends execution and flags for human takeover.
## AI Fields (dynamic content)
Set a field to `{"$ai":{}}` and add `ai_field_config`:
```json
{
"message": { "$ai": {} },
"provider_model_id": "uuid",
"ai_field_config": {
"message": { "mode": "prompt", "prompt": "Write a greeting for {{vars.name}}" }
}
}
```
references/triggers.md
# Workflow Triggers
Triggers are separate from the workflow graph. Do not store triggers in `workflow_graph`.
Trigger types:
- `inbound_message`: fires on WhatsApp message (requires `phone_number_id`).
- `api_call`: fires via Platform API.
- `whatsapp_event`: fires on WhatsApp events (requires `event`, optional `phone_number_id`).
- `project_event`: fires when a matching Project Event is emitted (requires `event_name`, optional property filter).
Use these scripts:
- `scripts/list-triggers.js <workflow-id>`
- `scripts/create-trigger.js <workflow-id> --trigger-type <inbound_message|api_call|whatsapp_event|project_event> ...`
- `scripts/update-trigger.js --trigger-id <id> --active true|false`
- `scripts/delete-trigger.js --trigger-id <id>`
- `scripts/list-whatsapp-phone-numbers.js` (to find `phone_number_id`)
Notes:
- For inbound message triggers, use `phone_number_id` (Meta ID).
- For whatsapp_event triggers, use `event` like `whatsapp.message.received`. Supported events: `whatsapp.message.received`, `whatsapp.message.sent`, `whatsapp.message.failed`, `whatsapp.conversation.created`, `whatsapp.conversation.ended`. `whatsapp.message.delivered` and `whatsapp.message.read` are not available for new triggers — use webhooks for delivery and read receipts.
- For API triggers, no extra fields are required.
- For project_event triggers, use `triggerable_attributes: { event_name, property_key?, operator?, property_value? }`.
- Project Event names must be lowercase dotted snake_case, for example `conversation.csat_scored`.
- Supported property filter operators are `eq`, `lt`, `lte`, `gt`, and `gte`; `property_value` must be non-null when provided.
- Project-event-triggered workflows run as observer/read-only executions and cannot emit more Project Events.
references/workflow-overview.md
# Workflow Overview
## Key behaviors
- Workflows are automation graphs; an execution moves node-to-node until it waits or ends.
- Trigger inbound_message: if a non-observer execution is running or waiting for that conversation, the message routes to it. If in handoff, workflow processing is skipped. Otherwise, a new execution starts.
- Trigger api_call: starts an execution from the API; context channel is api.
- Trigger whatsapp_event: starts an observer execution for each matching event; observer executions are read-only by default (allow_outbound: false).
## Execution states
- running: processing steps.
- waiting: paused waiting for user input (wait_for_response or agent nodes).
- handoff: halted for human takeover; automation does not process inbound messages.
- ended: terminal success.
- failed: terminal error; error_details recorded.
Valid transitions:
- running -> waiting | ended | failed | handoff
- waiting -> running | ended | failed | handoff
- handoff -> running | ended | failed
- ended/failed have no transitions
references/workflow-reference.md
# Reference
## Overview
This skill manages workflow graphs, triggers, executions, and functions. Prefer the Kapso CLI local source workflow for normal workflow/function edits; use these Platform API scripts as fallback tools for debugging, unsupported CLI tasks, and API-only environments. Variables CRUD is not supported and will return blocked responses.
For the CLI source-sync workflow and `@kapso/workflows`, read `references/local-workflow-source.md`.
## Environment
Required env vars:
- `KAPSO_API_BASE_URL` (host only, no `/platform/v1`, example: `https://api.kapso.ai`)
- `KAPSO_API_KEY`
## Scripts
Each script is a single operation. Run with `node` or `bun`.
- `scripts/get-graph.js`
- `scripts/list-workflows.js`
- `scripts/get-workflow.js`
- `scripts/create-workflow.js`
- `scripts/update-workflow-settings.js`
- `scripts/edit-graph.js`
- `scripts/update-graph.js`
- `scripts/validate-graph.js`
- `scripts/list-triggers.js`
- `scripts/create-trigger.js`
- `scripts/update-trigger.js`
- `scripts/delete-trigger.js`
- `scripts/list-executions.js`
- `scripts/get-execution.js`
- `scripts/get-context-value.js`
- `scripts/update-execution-status.js`
- `scripts/resume-execution.js`
- `scripts/variables-list.js`
- `scripts/variables-set.js` (blocked)
- `scripts/variables-delete.js` (blocked)
- `scripts/list-provider-models.js`
- `scripts/list-execution-events.js`
- `scripts/get-execution-event.js`
- `scripts/list-whatsapp-phone-numbers.js`
## Platform API endpoints
Implemented calls:
- `GET /platform/v1/workflows`
- `POST /platform/v1/workflows`
- `GET /platform/v1/workflows/:id`
- `GET /platform/v1/workflows/:id/definition` (fetch graph definition)
- `PATCH /platform/v1/workflows/:id` (update settings/definition)
- `GET /platform/v1/workflows/:id/variables` (workflow variable discovery)
- `GET /platform/v1/workflows/:workflow_id/triggers`
- `POST /platform/v1/workflows/:workflow_id/triggers`
- `PATCH /platform/v1/triggers/:id`
- `DELETE /platform/v1/triggers/:id`
- `GET /platform/v1/workflows/:workflow_id/executions`
- `GET /platform/v1/workflow_executions/:id`
- `PATCH /platform/v1/workflow_executions/:id`
- `POST /platform/v1/workflow_executions/:id/resume`
- `GET /platform/v1/workflow_executions/:id/events`
- `GET /platform/v1/workflow_events/:id`
- `POST /platform/v1/events` (emit a Project Event)
- `GET /platform/v1/events` (list Project Events)
- `GET /platform/v1/provider_models`
- `GET /platform/v1/whatsapp/phone_numbers` (for inbound_message triggers)
Variables CRUD endpoints are not defined for Platform API. Scripts intentionally return blocked for create/update/delete operations.
Workflow execution list endpoints use cursor pagination. Prefer `limit`, `after`, and `before`;
responses include a `paging` object. Do not use `page` / `per_page` for new workflow execution
or execution event queries.
## Workflow graphs: endpoints, shapes, and roundtrips
- `GET /platform/v1/workflows/:id` returns workflow metadata (including `lock_version`) but does NOT include `definition`.
- `GET /platform/v1/workflows/:id/definition` returns a workflow record that includes `definition` (nodes + edges).
- `PATCH /platform/v1/workflows/:id` accepts either `workflow: { ... }` or `flow: { ... }` envelopes.
- To update the graph: send `workflow: { definition: <definition> }`.
Graph shapes:
- `get-graph.js` returns a ReactFlow-style definition that includes extra/computed fields (`node.type`, `data.display_name`, `edge.id`, `edge.type`).
- The API accepts a minimal editable definition (`nodes[].id/position/data.node_type/data.config` and `edges[].source/target/label`) and ignores/strips extra fields.
Source of truth: `references/graph-contract.md`.
## Project Events
Use Project Events for durable workflow outcomes, scores, labels, and business facts that Kapso should remember, trigger workflows from, or forward via `project.event` webhooks.
- Emit from API: `POST /platform/v1/events`
- List from API: `GET /platform/v1/events`
- Manage definitions with `GET/POST/PATCH /platform/v1/event-definitions`
- Trigger workflows with `trigger_type: project_event` and `triggerable_attributes: { event_name, property_key?, operator?, property_value? }`
- Emit inside workflows with an `emit_event` node, a Function node `project_events` response, or an Agent node `emit_event` default tool.
- Project-event-triggered workflows cannot emit Project Events.
## Phone number lookup for triggers
Use `scripts/list-whatsapp-phone-numbers.js` to find `phone_number_id` for inbound_message triggers.
Do not use `/whatsapp_configs` (not a Platform API endpoint).
## Graph validation rules (local)
The `scripts/validate-graph.js` script checks:
- Exactly one start node with `id` = `start` and `data.node_type` = `start`.
- Unique node IDs and valid edge source/target IDs.
- Non-empty edge labels.
- Only `decide` nodes may branch; other nodes may have 0 or 1 outgoing `next` edge.
- Decide node condition labels must match outgoing edge labels.
Warnings are emitted for unknown node types or extra decide edges. Treat warnings as blockers before you PATCH a graph.
## Assets
- `assets/workflow-linear.json` (simple linear example)
- `assets/workflow-decision.json` (wait + decide example)
scripts/create-function.js
import { readFileSync } from 'node:fs';
import { kapsoConfigFromEnv, kapsoRequest } from './lib/functions/kapso-api.js';
import {
hasHelpFlag,
parseBooleanFlag,
parseFlags,
requireFlag
} from './lib/functions/args.js';
function ok(data) {
return { ok: true, data };
}
function err(message, details) {
return { ok: false, error: { message, details } };
}
function resolveCode(flags) {
if (typeof flags.code === 'string' && flags.code.length > 0) {
return flags.code;
}
if (typeof flags['code-file'] === 'string' && flags['code-file'].length > 0) {
return readFileSync(flags['code-file'], 'utf8');
}
throw new Error('Provide --code or --code-file');
}
async function main() {
const argv = process.argv.slice(2);
if (hasHelpFlag(argv)) {
console.log(
JSON.stringify(
{
ok: true,
usage:
'node scripts/create-function.js --name <name> (--code <js> | --code-file <path>) [--description <text>] [--public-endpoint <true|false>]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
},
null,
2
)
);
return 0;
}
try {
const flags = parseFlags(argv);
const name = requireFlag(flags, 'name');
const code = resolveCode(flags);
const payload = { name, code };
const publicEndpoint = parseBooleanFlag(flags, 'public-endpoint');
if (typeof flags.description === 'string' && flags.description.length > 0) {
payload.description = flags.description;
}
if (publicEndpoint !== undefined) {
payload.public_endpoint = publicEndpoint;
}
const config = kapsoConfigFromEnv();
const data = await kapsoRequest(config, '/platform/v1/functions', {
method: 'POST',
body: JSON.stringify({ function: payload })
});
console.log(JSON.stringify(ok(data), null, 2));
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(JSON.stringify(err('Command failed', { message }), null, 2));
return 1;
}
}
main().then((code) => process.exit(code));
scripts/create-trigger.js
#!/usr/bin/env node
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag } from './lib/workflows/args.js';
function usage() {
return ok({
usage: 'node scripts/create-trigger.js <workflow-id> --trigger-type <inbound_message|api_call|whatsapp_event|project_event> [--phone-number-id <id>] [--event <whatsapp.event>] [--active true|false] [--triggerable-attributes <json>]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
function parseBoolean(value) {
if (value === undefined) return undefined;
if (value === true) return true;
const lowered = String(value).toLowerCase();
if (lowered === 'true') return true;
if (lowered === 'false') return false;
return undefined;
}
function parseJson(value) {
if (!value) return undefined;
return JSON.parse(value);
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const workflowId = parsed.args[0] || getFlag(parsed.flags, 'workflow-id');
if (!workflowId) {
printJson(err('workflow_id is required'));
return 2;
}
const triggerType = getFlag(parsed.flags, 'trigger-type');
if (!triggerType) {
printJson(err('trigger-type is required'));
return 2;
}
const active = parseBoolean(getFlag(parsed.flags, 'active'));
const phoneNumberId = getFlag(parsed.flags, 'phone-number-id');
const whatsappConfigId = getFlag(parsed.flags, 'whatsapp-config-id');
const event = getFlag(parsed.flags, 'event');
let triggerableAttributes;
try {
triggerableAttributes = parseJson(getFlag(parsed.flags, 'triggerable-attributes'));
} catch (error) {
printJson(err('Invalid JSON for triggerable-attributes', { message: String(error?.message || error) }));
return 2;
}
const triggerPayload = {
trigger_type: triggerType
};
if (active !== undefined) triggerPayload.active = active;
if (phoneNumberId) triggerPayload.phone_number_id = phoneNumberId;
if (whatsappConfigId) triggerPayload.whatsapp_config_id = whatsappConfigId;
if (event) triggerPayload.event = event;
if (triggerableAttributes) triggerPayload.triggerable_attributes = triggerableAttributes;
const config = loadConfig();
const response = await requestJson(config, {
method: 'POST',
path: `/platform/v1/workflows/${workflowId}/triggers`,
body: { trigger: triggerPayload }
});
if (!response.ok) {
printJson(err('Failed to create trigger', response.raw, false, response.status));
return 2;
}
printJson(ok({ trigger: response.data }));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/create-workflow.js
#!/usr/bin/env node
import fs from 'fs';
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag } from './lib/workflows/args.js';
function usage() {
return ok({
usage: 'node scripts/create-workflow.js --name <name> [--description <text>] [--definition-file <path> | --definition-json <json>]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
function loadDefinition({ filePath, jsonText }) {
if (filePath) {
const raw = fs.readFileSync(filePath, 'utf8');
return JSON.parse(raw);
}
if (jsonText) {
return JSON.parse(jsonText);
}
return undefined;
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const name = getFlag(parsed.flags, 'name');
if (!name) {
printJson(err('name is required'));
return 2;
}
const description = getFlag(parsed.flags, 'description');
const definitionFile = getFlag(parsed.flags, 'definition-file');
const definitionJson = getFlag(parsed.flags, 'definition-json');
if (definitionFile && definitionJson) {
printJson(err('Provide only one of --definition-file or --definition-json'));
return 2;
}
let definition;
try {
definition = loadDefinition({ filePath: definitionFile, jsonText: definitionJson });
} catch (error) {
printJson(err('Failed to parse workflow definition JSON', { message: String(error?.message || error) }));
return 2;
}
const payload = {
workflow: {
name,
description
}
};
if (definition) {
payload.workflow.definition = definition;
}
const config = loadConfig();
const response = await requestJson(config, {
method: 'POST',
path: '/platform/v1/workflows',
body: payload
});
if (!response.ok) {
printJson(err('Failed to create workflow', response.raw, false, response.status));
return 2;
}
printJson(ok({ workflow: response.data }));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/delete-trigger.js
#!/usr/bin/env node
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag } from './lib/workflows/args.js';
function usage() {
return ok({
usage: 'node scripts/delete-trigger.js --trigger-id <id>',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const triggerId = getFlag(parsed.flags, 'trigger-id');
if (!triggerId) {
printJson(err('trigger-id is required'));
return 2;
}
const config = loadConfig();
const response = await requestJson(config, {
method: 'DELETE',
path: `/platform/v1/triggers/${triggerId}`
});
if (!response.ok) {
printJson(err('Failed to delete trigger', response.raw, false, response.status));
return 2;
}
printJson(ok({ deleted: true, status: response.status }));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/deploy-function.js
import { kapsoConfigFromEnv, kapsoRequest } from './lib/functions/kapso-api.js';
import { hasHelpFlag, parseFlags, requireFlag } from './lib/functions/args.js';
function ok(data) {
return { ok: true, data };
}
function err(message, details) {
return { ok: false, error: { message, details } };
}
async function main() {
const argv = process.argv.slice(2);
if (hasHelpFlag(argv)) {
console.log(
JSON.stringify(
{
ok: true,
usage: 'node scripts/deploy-function.js --function-id <id>',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
},
null,
2
)
);
return 0;
}
try {
const flags = parseFlags(argv);
const functionId = requireFlag(flags, 'function-id');
const config = kapsoConfigFromEnv();
const data = await kapsoRequest(config, `/platform/v1/functions/${encodeURIComponent(functionId)}/deploy`, {
method: 'POST',
body: JSON.stringify({})
});
console.log(JSON.stringify(ok(data), null, 2));
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(JSON.stringify(err('Command failed', { message }), null, 2));
return 1;
}
}
main().then((code) => process.exit(code));
scripts/edit-graph.js
#!/usr/bin/env node
import { createHash } from 'crypto';
import { readFileSync } from 'fs';
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag, getNumberFlag } from './lib/workflows/args.js';
function sha256(text) {
return createHash('sha256').update(text).digest('hex');
}
function normalizeLineEndings(text) {
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
}
function stripCodeFences(text) {
const trimmed = text.trim();
if (trimmed.startsWith('```')) {
const withoutFirst = trimmed.replace(/^```[a-zA-Z0-9_-]*\n?/, '');
return withoutFirst.replace(/```$/, '').trim();
}
return text;
}
function stripLineNumbers(text) {
return text
.split('\n')
.map((line) => line.replace(/^\s*\d+\s*\|\s?/, '').replace(/^\s*\d+:\s?/, ''))
.join('\n');
}
function normalizeEditText(text) {
return normalizeLineEndings(stripLineNumbers(stripCodeFences(text)));
}
function readFileText(path) {
return readFileSync(path, 'utf8');
}
function loadTextInput(flags, valueFlag, fileFlag) {
const filePath = getFlag(flags, fileFlag);
if (filePath) {
return readFileText(filePath);
}
return getFlag(flags, valueFlag);
}
function isEscaped(text, index) {
let backslashes = 0;
for (let i = index - 1; i >= 0 && text[i] === '\\'; i -= 1) {
backslashes += 1;
}
return backslashes % 2 === 1;
}
function insideJsonString(text, index) {
let inString = false;
for (let i = 0; i < index; i += 1) {
if (text[i] === '"' && !isEscaped(text, i)) {
inString = !inString;
}
}
return inString;
}
function unescapeCLike(text) {
return text
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\t/g, '\t')
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\');
}
function jsonStringContent(text) {
const json = JSON.stringify(text);
return json.slice(1, -1);
}
function replaceAllOccurrences(content, search, newText) {
let result = '';
let index = 0;
let replacementsInJson = 0;
let replacementsOutsideJson = 0;
while (true) {
const matchIndex = content.indexOf(search, index);
if (matchIndex === -1) {
result += content.slice(index);
break;
}
const insideString = insideJsonString(content, matchIndex);
const replacement = insideString
? jsonStringContent(unescapeCLike(newText))
: newText;
result += content.slice(index, matchIndex) + replacement;
index = matchIndex + search.length;
if (insideString) {
replacementsInJson += 1;
} else {
replacementsOutsideJson += 1;
}
}
return {
content: result,
replacements: replacementsInJson + replacementsOutsideJson,
replacements_in_json_strings: replacementsInJson,
replacements_outside_json_strings: replacementsOutsideJson
};
}
function applyReplacement(content, oldText, newText, replaceAll) {
if (oldText === newText) {
throw new Error('old and new text must be different');
}
const candidates = [
{ label: 'raw', value: oldText },
{ label: 'unescaped', value: oldText.replace(/\\n/g, '\n') },
{ label: 'escaped', value: oldText.replace(/\n/g, '\\n') }
];
const seen = new Set();
const uniqueCandidates = candidates.filter((candidate) => {
if (seen.has(candidate.value)) return false;
seen.add(candidate.value);
return true;
});
for (const candidate of uniqueCandidates) {
if (!candidate.value) continue;
const firstIndex = content.indexOf(candidate.value);
if (firstIndex === -1) continue;
if (replaceAll) {
const replaced = replaceAllOccurrences(content, candidate.value, newText);
return { ...replaced, matchedVariant: candidate.label };
}
const lastIndex = content.lastIndexOf(candidate.value);
if (firstIndex !== lastIndex) {
throw new Error('old text matches multiple locations; use --replace-all or narrow the match');
}
const insideString = insideJsonString(content, firstIndex);
const replacement = insideString
? jsonStringContent(unescapeCLike(newText))
: newText;
const updated = content.replace(candidate.value, replacement);
return {
content: updated,
replacements: 1,
replacements_in_json_strings: insideString ? 1 : 0,
replacements_outside_json_strings: insideString ? 0 : 1,
matchedVariant: candidate.label
};
}
throw new Error('old text not found in workflow graph');
}
function usage() {
return ok({
usage: 'node scripts/edit-graph.js <workflow-id> --expected-lock-version <n> --old <text>|--old-file <path> --new <text>|--new-file <path> [--replace-all]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const workflowId = parsed.args[0] || getFlag(parsed.flags, 'workflow-id');
if (!workflowId) {
printJson(err('workflow_id is required'));
return 2;
}
const expectedLockVersion = getNumberFlag(parsed.flags, 'expected-lock-version')
?? getNumberFlag(parsed.flags, 'lock-version');
if (expectedLockVersion === undefined) {
printJson(err('expected-lock-version is required'));
return 2;
}
const oldInput = loadTextInput(parsed.flags, 'old', 'old-file');
const newInput = loadTextInput(parsed.flags, 'new', 'new-file');
if (!oldInput || !newInput) {
printJson(err('old/new text is required (use --old/--new or --old-file/--new-file)'));
return 2;
}
const replaceAll = getBooleanFlag(parsed.flags, 'replace-all');
const config = loadConfig();
const response = await requestJson(config, {
method: 'GET',
path: `/platform/v1/workflows/${workflowId}/definition`
});
if (!response.ok) {
printJson(err('Failed to fetch workflow definition', response.raw, false, response.status));
return 2;
}
const workflow = response.data;
const definition = workflow && typeof workflow === 'object' ? workflow.definition : null;
if (!definition || typeof definition !== 'object') {
printJson(err('Workflow definition missing in response', response.raw, false, response.status));
return 2;
}
const currentLock = workflow.lock_version;
if (currentLock !== expectedLockVersion) {
printJson(err('Conflict: workflow was modified. Refetch and retry.', {
expected_lock_version: expectedLockVersion,
current_lock_version: currentLock
}));
return 2;
}
const pretty = JSON.stringify(definition, null, 2);
const beforeHash = sha256(pretty);
const oldText = normalizeEditText(oldInput);
const newText = normalizeEditText(newInput);
let editResult;
try {
editResult = applyReplacement(pretty, oldText, newText, replaceAll);
} catch (error) {
printJson(err(String(error?.message || error)));
return 2;
}
let updatedDefinition;
try {
updatedDefinition = JSON.parse(editResult.content);
} catch (error) {
printJson(err('Invalid JSON after replacement', { message: String(error?.message || error) }));
return 2;
}
const update = await requestJson(config, {
method: 'PATCH',
path: `/platform/v1/workflows/${workflowId}`,
body: {
workflow: {
definition: updatedDefinition
}
}
});
if (!update.ok) {
printJson(err('Failed to update workflow definition', update.raw, false, update.status));
return 2;
}
printJson(ok({
workflow_id: workflowId,
replacements_count: editResult.replacements,
replacements_in_json_strings: editResult.replacements_in_json_strings,
replacements_outside_json_strings: editResult.replacements_outside_json_strings,
matched_variant: editResult.matchedVariant,
workflow_graph_sha256_before: beforeHash,
workflow_graph_sha256_after: sha256(editResult.content),
update: {
id: update.data.id,
name: update.data.name,
status: update.data.status,
lock_version: update.data.lock_version,
updated_at: update.data.updated_at
}
}));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/get-context-value.js
#!/usr/bin/env node
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag } from './lib/workflows/args.js';
function usage() {
return ok({
usage: 'node scripts/get-context-value.js <execution-id> --variable-path <path>',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY'],
examples: ['node scripts/get-context-value.js exec_123 --variable-path vars.user_name']
});
}
function getPathValue(obj, path) {
if (!path) return undefined;
const parts = path.split('.').filter(Boolean);
let current = obj;
for (const part of parts) {
if (current && Object.prototype.hasOwnProperty.call(current, part)) {
current = current[part];
} else {
return undefined;
}
}
return current;
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const executionId = parsed.args[0] || getFlag(parsed.flags, 'execution-id');
if (!executionId) {
printJson(err('execution_id is required'));
return 2;
}
const variablePath = getFlag(parsed.flags, 'variable-path');
if (!variablePath) {
printJson(err('variable-path is required'));
return 2;
}
const config = loadConfig();
const response = await requestJson(config, {
method: 'GET',
path: `/platform/v1/workflow_executions/${executionId}`
});
if (!response.ok) {
printJson(err('Failed to fetch execution', response.raw, false, response.status));
return 2;
}
const execution = response.data;
const executionContext = execution && execution.execution_context ? execution.execution_context : {};
let path = variablePath;
if (path.startsWith('execution_context.')) {
path = path.replace(/^execution_context\./, '');
}
const value = getPathValue(executionContext, path);
if (value === undefined) {
printJson(err('Path not found in execution_context', { variable_path: variablePath }));
return 2;
}
printJson(ok({ value, variable_path: variablePath, execution_id: executionId }));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/get-execution-event.js
#!/usr/bin/env node
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag } from './lib/workflows/args.js';
function usage() {
return ok({
usage: 'node scripts/get-execution-event.js <event-id> [--event-id <id>]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const eventId = parsed.args[0] || getFlag(parsed.flags, 'event-id');
if (!eventId) {
printJson(err('event_id is required'));
return 2;
}
const config = loadConfig();
const response = await requestJson(config, {
method: 'GET',
path: `/platform/v1/workflow_events/${eventId}`
});
if (!response.ok && response.status === 404) {
printJson(err('Execution event detail endpoint is not available in the Platform API.', {
endpoint: '/platform/v1/workflow_events/:id'
}, true, response.status));
return 2;
}
if (!response.ok) {
printJson(err('Failed to fetch execution event detail', response.raw, false, response.status));
return 2;
}
printJson(ok({
event_id: eventId,
event: response.data
}));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/get-execution.js
#!/usr/bin/env node
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag } from './lib/workflows/args.js';
function usage() {
return ok({
usage: 'node scripts/get-execution.js <execution-id> [--execution-id <id>]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const executionId = parsed.args[0] || getFlag(parsed.flags, 'execution-id');
if (!executionId) {
printJson(err('execution_id is required'));
return 2;
}
const config = loadConfig();
const response = await requestJson(config, {
method: 'GET',
path: `/platform/v1/workflow_executions/${executionId}`
});
if (!response.ok) {
printJson(err('Failed to fetch execution', response.raw, false, response.status));
return 2;
}
printJson(ok({ execution: response.data }));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/get-function.js
import { kapsoConfigFromEnv, kapsoRequest } from './lib/functions/kapso-api.js';
import { hasHelpFlag, parseFlags, requireFlag } from './lib/functions/args.js';
function ok(data) {
return { ok: true, data };
}
function err(message, details) {
return { ok: false, error: { message, details } };
}
async function main() {
const argv = process.argv.slice(2);
if (hasHelpFlag(argv)) {
console.log(
JSON.stringify(
{
ok: true,
usage: 'node scripts/get-function.js --function-id <id>',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
},
null,
2
)
);
return 0;
}
try {
const flags = parseFlags(argv);
const functionId = requireFlag(flags, 'function-id');
const config = kapsoConfigFromEnv();
const data = await kapsoRequest(config, `/platform/v1/functions/${encodeURIComponent(functionId)}`);
console.log(JSON.stringify(ok(data), null, 2));
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(JSON.stringify(err('Command failed', { message }), null, 2));
return 1;
}
}
main().then((code) => process.exit(code));
scripts/get-graph.js
#!/usr/bin/env node
import { createHash } from 'crypto';
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag } from './lib/workflows/args.js';
function sha256(text) {
return createHash('sha256').update(text).digest('hex');
}
function addLineNumbers(text) {
return text
.split('\n')
.map((line, index) => `${String(index + 1).padStart(4, ' ')} | ${line}`)
.join('\n');
}
function usage() {
return ok({
usage: 'node scripts/get-graph.js <workflow-id> [--workflow-id <id>]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const workflowId = parsed.args[0] || getFlag(parsed.flags, 'workflow-id');
if (!workflowId) {
printJson(err('workflow_id is required'));
return 2;
}
const config = loadConfig();
const response = await requestJson(config, {
method: 'GET',
path: `/platform/v1/workflows/${workflowId}/definition`
});
if (!response.ok) {
printJson(err('Failed to fetch workflow definition', response.raw, false, response.status));
return 2;
}
const workflow = response.data;
const definition = workflow && typeof workflow === 'object' ? workflow.definition : null;
if (!definition || typeof definition !== 'object') {
printJson(err('Workflow definition missing in response', response.raw, false, response.status));
return 2;
}
const pretty = JSON.stringify(definition, null, 2);
const hash = sha256(pretty);
const withLines = addLineNumbers(pretty);
const trimmedWorkflow = {
id: workflow.id,
name: workflow.name,
description: workflow.description,
status: workflow.status,
lock_version: workflow.lock_version,
message_debounce_seconds: workflow.message_debounce_seconds,
project_id: workflow.project_id,
updated_at: workflow.updated_at
};
printJson(ok({
workflow: trimmedWorkflow,
definition,
workflow_graph_pretty: pretty,
workflow_graph_with_lines: withLines,
workflow_graph_sha256: hash
}));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/get-workflow.js
#!/usr/bin/env node
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag } from './lib/workflows/args.js';
function usage() {
return ok({
usage: 'node scripts/get-workflow.js <workflow-id> [--workflow-id <id>]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const workflowId = parsed.args[0] || getFlag(parsed.flags, 'workflow-id');
if (!workflowId) {
printJson(err('workflow_id is required'));
return 2;
}
const config = loadConfig();
const response = await requestJson(config, {
method: 'GET',
path: `/platform/v1/workflows/${workflowId}`
});
if (!response.ok) {
printJson(err('Failed to fetch workflow', response.raw, false, response.status));
return 2;
}
printJson(ok({ workflow: response.data }));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/invoke-function.js
import { readFileSync } from 'node:fs';
import { kapsoConfigFromEnv, kapsoRequest } from './lib/functions/kapso-api.js';
import { hasHelpFlag, parseFlags, requireFlag, parseJsonValue } from './lib/functions/args.js';
function ok(data) {
return { ok: true, data };
}
function err(message, details) {
return { ok: false, error: { message, details } };
}
function resolvePayload(flags) {
if (typeof flags.payload === 'string' && flags.payload.length > 0) {
return parseJsonValue(flags.payload, 'payload');
}
if (typeof flags['payload-file'] === 'string' && flags['payload-file'].length > 0) {
return JSON.parse(readFileSync(flags['payload-file'], 'utf8'));
}
throw new Error('Provide --payload or --payload-file');
}
async function main() {
const argv = process.argv.slice(2);
if (hasHelpFlag(argv)) {
console.log(
JSON.stringify(
{
ok: true,
usage:
'node scripts/invoke-function.js --function-id <id> (--payload <json> | --payload-file <path>)',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
},
null,
2
)
);
return 0;
}
try {
const flags = parseFlags(argv);
const functionId = requireFlag(flags, 'function-id');
const payload = resolvePayload(flags);
const config = kapsoConfigFromEnv();
const data = await kapsoRequest(config, `/platform/v1/functions/${encodeURIComponent(functionId)}/invoke`, {
method: 'POST',
body: JSON.stringify(payload)
});
console.log(JSON.stringify(ok(data), null, 2));
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(JSON.stringify(err('Command failed', { message }), null, 2));
return 1;
}
}
main().then((code) => process.exit(code));
scripts/lib/functions/args.js
function hasHelpFlag(argv) {
return argv.includes('--help') || argv.includes('-h');
}
function parseFlags(argv) {
const flags = {};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (!arg.startsWith('--')) {
continue;
}
const trimmed = arg.slice(2);
const eqIndex = trimmed.indexOf('=');
if (eqIndex >= 0) {
const key = trimmed.slice(0, eqIndex);
const value = trimmed.slice(eqIndex + 1);
flags[key] = value;
continue;
}
const next = argv[index + 1];
if (!next || next.startsWith('--')) {
flags[trimmed] = true;
continue;
}
flags[trimmed] = next;
index += 1;
}
return flags;
}
function requireFlag(flags, name) {
const value = flags[name];
if (typeof value !== 'string' || value.length === 0) {
throw new Error(`Missing required flag --${name}`);
}
return value;
}
function parseBooleanFlag(flags, name) {
const value = flags[name];
if (value === undefined) {
return undefined;
}
if (value === true) {
return true;
}
const normalized = String(value).toLowerCase();
if (['true', '1', 'yes'].includes(normalized)) {
return true;
}
if (['false', '0', 'no'].includes(normalized)) {
return false;
}
throw new Error(`Invalid boolean for --${name}: ${String(value)}`);
}
function parseEnumFlag(flags, name, allowedValues) {
const value = flags[name];
if (value === undefined) {
return undefined;
}
const normalized = String(value);
if (allowedValues.includes(normalized)) {
return normalized;
}
throw new Error(
`Invalid value for --${name}: ${normalized}. Expected one of: ${allowedValues.join(', ')}`
);
}
function parseJsonValue(value, name) {
if (value === undefined || value === true) {
throw new Error(`Missing required JSON for --${name}`);
}
try {
return JSON.parse(value);
} catch (error) {
throw new Error(`Invalid JSON for --${name}: ${String(error)}`);
}
}
export {
hasHelpFlag,
parseFlags,
requireFlag,
parseBooleanFlag,
parseEnumFlag,
parseJsonValue
};
scripts/lib/functions/kapso-api.js
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required env var: ${name}`);
}
return value;
}
function normalizeBaseUrl(raw) {
return raw.replace(/\/+$/, '');
}
function isLocalhost(hostname) {
return hostname === 'localhost' || hostname === '127.0.0.1';
}
function validateBaseUrl(baseUrl) {
if (!baseUrl) return;
let parsed;
try {
parsed = new URL(baseUrl);
} catch (error) {
throw new Error(`Invalid KAPSO_API_BASE_URL: ${baseUrl}`);
}
if (!process.env.KAPSO_API_ALLOW_LOCALHOST && isLocalhost(parsed.hostname)) {
throw new Error(
`KAPSO_API_BASE_URL points to localhost (${parsed.hostname}). ` +
'Set KAPSO_API_ALLOW_LOCALHOST=true if this is intentional.'
);
}
}
function kapsoConfigFromEnv() {
const baseUrl = normalizeBaseUrl(requireEnv('KAPSO_API_BASE_URL'));
validateBaseUrl(baseUrl);
return {
baseUrl,
apiKey: requireEnv('KAPSO_API_KEY')
};
}
async function kapsoRequest(config, path, init = {}) {
const url = `${config.baseUrl}${path}`;
const headers = new Headers(init.headers || undefined);
headers.set('X-API-Key', config.apiKey);
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
let response;
try {
response = await fetch(url, { ...init, headers });
} catch (error) {
throw new Error(
`Kapso API request failed (network error) url=${url} error=${String(error?.message || error)}`
);
}
const text = await response.text();
if (!response.ok) {
throw new Error(`Kapso API request failed (status=${response.status}) body=${text}`);
}
const contentType = response.headers.get('content-type') || '';
if (!text) {
return {};
}
if (contentType.includes('application/json')) {
return JSON.parse(text);
}
return text;
}
export {
kapsoConfigFromEnv,
kapsoRequest
};
scripts/lib/workflows/args.js
export function parseArgs(argv) {
const flags = {};
const args = [];
let i = 0;
while (i < argv.length) {
const token = argv[i];
if (token.startsWith('--')) {
const trimmed = token.slice(2);
const eqIndex = trimmed.indexOf('=');
if (eqIndex !== -1) {
const key = trimmed.slice(0, eqIndex);
const value = trimmed.slice(eqIndex + 1);
flags[key] = value;
i += 1;
continue;
}
const key = trimmed;
const next = argv[i + 1];
if (!next || next.startsWith('--')) {
flags[key] = true;
i += 1;
} else {
flags[key] = next;
i += 2;
}
continue;
}
args.push(token);
i += 1;
}
return { args, flags };
}
export function getFlag(flags, name) {
const value = flags[name];
if (typeof value === 'string') return value;
return undefined;
}
export function getBooleanFlag(flags, name) {
return Boolean(flags[name]);
}
export function getNumberFlag(flags, name) {
const value = getFlag(flags, name);
if (!value) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
scripts/lib/workflows/kapso-api.js
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required env var: ${name}`);
}
return value;
}
function normalizeBaseUrl(raw) {
return raw.replace(/\/+$/, '');
}
function isLocalhost(hostname) {
return hostname === 'localhost' || hostname === '127.0.0.1';
}
function validateBaseUrl(baseUrl) {
if (!baseUrl) return;
let parsed;
try {
parsed = new URL(baseUrl);
} catch (error) {
throw new Error(`Invalid KAPSO_API_BASE_URL: ${baseUrl}`);
}
if (!process.env.KAPSO_API_ALLOW_LOCALHOST && isLocalhost(parsed.hostname)) {
throw new Error(
`KAPSO_API_BASE_URL points to localhost (${parsed.hostname}). ` +
'Set KAPSO_API_ALLOW_LOCALHOST=true if this is intentional.'
);
}
}
export function loadConfig(options = {}) {
const requireApi = options.requireApi !== false;
const rawBaseUrl = requireApi ? requireEnv('KAPSO_API_BASE_URL') : (process.env.KAPSO_API_BASE_URL || '');
const baseUrl = rawBaseUrl ? normalizeBaseUrl(rawBaseUrl) : '';
validateBaseUrl(baseUrl);
const apiKey = requireApi ? requireEnv('KAPSO_API_KEY') : (process.env.KAPSO_API_KEY || '');
return { baseUrl, apiKey };
}
function buildUrl(baseUrl, path) {
const trimmed = baseUrl.replace(/\/+$/, '');
const safePath = path.startsWith('/') ? path.slice(1) : path;
return `${trimmed}/${safePath}`;
}
export async function requestJson(config, options) {
const url = new URL(buildUrl(config.baseUrl, options.path));
if (options.query) {
Object.entries(options.query).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
url.searchParams.set(key, String(value));
});
}
const headers = {
Accept: 'application/json'
};
if (config.apiKey) {
headers['X-API-Key'] = config.apiKey;
}
let body;
if (options.body !== undefined) {
headers['Content-Type'] = 'application/json';
body = JSON.stringify(options.body);
}
let response;
try {
response = await fetch(url.toString(), {
method: options.method,
headers,
body
});
} catch (error) {
return {
ok: false,
status: 0,
error: 'Network error while calling Kapso API',
raw: { message: String(error?.message || error), url: url.toString() }
};
}
const text = await response.text();
let parsed = text;
if (text) {
try {
parsed = JSON.parse(text);
} catch {
parsed = text;
}
}
if (response.ok) {
const data = (parsed && typeof parsed === 'object' && 'data' in parsed)
? parsed.data
: parsed;
return {
ok: true,
status: response.status,
data,
raw: parsed
};
}
const message = (parsed && typeof parsed === 'object' && 'error' in parsed)
? String(parsed.error)
: `HTTP ${response.status}`;
return {
ok: false,
status: response.status,
error: message,
raw: parsed
};
}
scripts/lib/workflows/result.js
export function ok(data) {
return { ok: true, data };
}
export function err(message, details, blocked, status) {
const error = { message };
if (details !== undefined) error.details = details;
if (blocked !== undefined) error.blocked = blocked;
if (status !== undefined) error.status = status;
return { ok: false, error };
}
export function printJson(value) {
// eslint-disable-next-line no-console
console.log(JSON.stringify(value, null, 2));
}
scripts/list-execution-events.js
#!/usr/bin/env node
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag, getNumberFlag } from './lib/workflows/args.js';
function usage() {
return ok({
usage:
'node scripts/list-execution-events.js <execution-id> [--event-type <type>] [--limit <n>] [--after <cursor>] [--before <cursor>]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const executionId = parsed.args[0] || getFlag(parsed.flags, 'execution-id');
if (!executionId) {
printJson(err('execution_id is required'));
return 2;
}
const config = loadConfig();
const response = await requestJson(config, {
method: 'GET',
path: `/platform/v1/workflow_executions/${executionId}/events`,
query: {
event_type: getFlag(parsed.flags, 'event-type'),
limit: getNumberFlag(parsed.flags, 'limit'),
after: getFlag(parsed.flags, 'after'),
before: getFlag(parsed.flags, 'before')
}
});
if (!response.ok && response.status === 404) {
printJson(err('Execution events endpoint is not available in the Platform API.', {
endpoint: '/platform/v1/workflow_executions/:id/events'
}, true, response.status));
return 2;
}
if (!response.ok) {
printJson(err('Failed to fetch execution events', response.raw, false, response.status));
return 2;
}
printJson(ok({
execution_id: executionId,
events: response.data,
paging: response.raw?.paging
}));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/list-executions.js
#!/usr/bin/env node
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag, getNumberFlag } from './lib/workflows/args.js';
function usage() {
return ok({
usage: 'node scripts/list-executions.js <workflow-id> [--status <status>] [--waiting-reason <value>] [--whatsapp-conversation-id <id>] [--created-after <iso>] [--created-before <iso>] [--limit <n>] [--after <cursor>] [--before <cursor>]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const workflowId = parsed.args[0] || getFlag(parsed.flags, 'workflow-id');
if (!workflowId) {
printJson(err('workflow_id is required'));
return 2;
}
const config = loadConfig();
const response = await requestJson(config, {
method: 'GET',
path: `/platform/v1/workflows/${workflowId}/executions`,
query: {
status: getFlag(parsed.flags, 'status'),
waiting_reason: getFlag(parsed.flags, 'waiting-reason'),
whatsapp_conversation_id: getFlag(parsed.flags, 'whatsapp-conversation-id'),
created_after: getFlag(parsed.flags, 'created-after'),
created_before: getFlag(parsed.flags, 'created-before'),
limit: getNumberFlag(parsed.flags, 'limit'),
after: getFlag(parsed.flags, 'after'),
before: getFlag(parsed.flags, 'before')
}
});
if (!response.ok) {
printJson(err('Failed to list executions', response.raw, false, response.status));
return 2;
}
printJson(ok({ executions: response.data, paging: response.raw?.paging }));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/list-function-invocations.js
import { hasHelpFlag, parseFlags, requireFlag } from './lib/functions/args.js';
import { kapsoConfigFromEnv, kapsoRequest } from './lib/functions/kapso-api.js';
function ok(data) {
return { ok: true, data };
}
function err(message, details) {
return { ok: false, error: { message, details } };
}
async function main() {
const argv = process.argv.slice(2);
if (hasHelpFlag(argv)) {
console.log(
JSON.stringify(
{
ok: true,
usage:
'node scripts/list-function-invocations.js --function-id <id> [--status <success|failed>] [--limit <n>]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
},
null,
2
)
);
return 0;
}
try {
const flags = parseFlags(argv);
const functionId = requireFlag(flags, 'function-id');
const params = new URLSearchParams();
if (flags.status) params.set('status', flags.status);
if (flags.limit) params.set('limit', flags.limit);
const config = kapsoConfigFromEnv();
const data = await kapsoRequest(
config,
`/platform/v1/functions/${encodeURIComponent(functionId)}/invocations${params.toString() ? `?${params.toString()}` : ''}`
);
console.log(JSON.stringify(ok(data), null, 2));
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(JSON.stringify(err('Command failed', { message }), null, 2));
return 1;
}
}
main().then((code) => process.exit(code));
scripts/list-functions.js
import { kapsoConfigFromEnv, kapsoRequest } from './lib/functions/kapso-api.js';
import { hasHelpFlag } from './lib/functions/args.js';
function ok(data) {
return { ok: true, data };
}
function err(message, details) {
return { ok: false, error: { message, details } };
}
async function main() {
const argv = process.argv.slice(2);
if (hasHelpFlag(argv)) {
console.log(
JSON.stringify(
{
ok: true,
usage: 'node scripts/list-functions.js',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
},
null,
2
)
);
return 0;
}
try {
const config = kapsoConfigFromEnv();
const data = await kapsoRequest(config, '/platform/v1/functions');
console.log(JSON.stringify(ok(data), null, 2));
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(JSON.stringify(err('Command failed', { message }), null, 2));
return 1;
}
}
main().then((code) => process.exit(code));
scripts/list-provider-models.js
#!/usr/bin/env node
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getBooleanFlag } from './lib/workflows/args.js';
function usage() {
return ok({
usage: 'node scripts/list-provider-models.js',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const config = loadConfig();
const response = await requestJson(config, {
method: 'GET',
path: '/platform/v1/provider_models'
});
if (!response.ok && response.status === 404) {
printJson(err('Provider models endpoint is not available in the Platform API.', {
endpoint: '/platform/v1/provider_models'
}, true, response.status));
return 2;
}
if (!response.ok) {
printJson(err('Failed to fetch provider models', response.raw, false, response.status));
return 2;
}
printJson(ok({
provider_models: response.data
}));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/list-triggers.js
#!/usr/bin/env node
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag } from './lib/workflows/args.js';
function usage() {
return ok({
usage: 'node scripts/list-triggers.js <workflow-id> [--workflow-id <id>]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const workflowId = parsed.args[0] || getFlag(parsed.flags, 'workflow-id');
if (!workflowId) {
printJson(err('workflow_id is required'));
return 2;
}
const config = loadConfig();
const response = await requestJson(config, {
method: 'GET',
path: `/platform/v1/workflows/${workflowId}/triggers`
});
if (!response.ok) {
printJson(err('Failed to list triggers', response.raw, false, response.status));
return 2;
}
printJson(ok({ triggers: response.data }));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/list-whatsapp-phone-numbers.js
#!/usr/bin/env node
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getBooleanFlag, getNumberFlag } from './lib/workflows/args.js';
function usage() {
return ok({
usage: 'node scripts/list-whatsapp-phone-numbers.js [--per-page <n>] [--page <n>]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
function extractPhoneNumbers(payload) {
if (Array.isArray(payload?.data)) return payload.data;
if (Array.isArray(payload?.phone_numbers)) return payload.phone_numbers;
if (Array.isArray(payload)) return payload;
return [];
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const config = loadConfig();
const response = await requestJson(config, {
method: 'GET',
path: '/platform/v1/whatsapp/phone_numbers',
query: {
per_page: getNumberFlag(parsed.flags, 'per-page'),
page: getNumberFlag(parsed.flags, 'page')
}
});
if (!response.ok) {
printJson(err('Failed to list WhatsApp phone numbers', response.raw, false, response.status));
return 2;
}
const payload = response.data;
const phoneNumbers = extractPhoneNumbers(payload);
printJson(ok({
phone_numbers: phoneNumbers,
raw: payload,
note: 'Use phone_number_id for inbound_message triggers.'
}));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/list-workflows.js
#!/usr/bin/env node
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag } from './lib/workflows/args.js';
function usage() {
return ok({
usage: 'node scripts/list-workflows.js [--status <status>] [--name-contains <text>] [--created-after <iso>] [--created-before <iso>]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const config = loadConfig();
const response = await requestJson(config, {
method: 'GET',
path: '/platform/v1/workflows',
query: {
status: getFlag(parsed.flags, 'status'),
name_contains: getFlag(parsed.flags, 'name-contains'),
created_after: getFlag(parsed.flags, 'created-after'),
created_before: getFlag(parsed.flags, 'created-before')
}
});
if (!response.ok) {
printJson(err('Failed to list workflows', response.raw, false, response.status));
return 2;
}
printJson(ok({ workflows: response.data }));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/openapi-explore.mjs
#!/usr/bin/env node
import { readFileSync, readdirSync, statSync } from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { parse as parseYaml } from 'yaml'
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head']
const DEFAULT_PUBLISHED_WHATSAPP_OPENAPI = 'https://docs.kapso.ai/api/meta/whatsapp/openapi-whatsapp.yaml'
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
function printHelp(exitCode = 0) {
const msg = `
Explore OpenAPI specs (YAML)
Usage:
node openapi-explore.mjs [openapi.yaml ...] <command> [args]
node openapi-explore.mjs [--file <path> ...] <command> [args]
node openapi-explore.mjs --all <command> [args]
Default (no files passed):
Loads the published OpenAPI files from docs.kapso.ai:
- ${DEFAULT_PUBLISHED_WHATSAPP_OPENAPI}
- (plus platform + workflows, deduced from that URL)
Fallback:
If it can't fetch the published specs (offline, etc.), it falls back to local repo files:
api/**/openapi-*.yaml
Use --all to force local auto-discovery.
Commands:
specs
tags [--spec <id>]
ops [--spec <id>] [--tag <tag>] [--q <query>]
search <query> [--spec <id>]
op <operationId | specId:operationId | "METHOD /path"> [--spec <id>]
schema <SchemaName | specId:SchemaName> [--spec <id>]
where <SchemaName | specId:SchemaName> [--spec <id>]
Flags:
--file, -f <path> Load a spec file (repeatable)
--all Load all api/**/openapi-*.yaml files
--spec <id> Filter by spec id (platform, workflows, whatsapp, ...)
--json Output JSON (search/ops/where)
--limit <n> Limit results (default 30)
`
console.log(msg.trim())
process.exit(exitCode)
}
function die(msg, exitCode = 1) {
console.error(msg)
process.exit(exitCode)
}
function parseArgs(argv) {
const args = [...argv]
const opts = { files: [], all: false, spec: null, json: false, limit: 30, tag: null, q: null }
let command = null
const rest = []
for (let i = 0; i < args.length; i++) {
const a = args[i]
if (a === '--help' || a === '-h') printHelp(0)
if (a === '--all') {
opts.all = true
continue
}
if (a === '--json') {
opts.json = true
continue
}
if (a === '--file' || a === '-f') {
const p = args[++i]
if (!p) die('Missing value for --file')
opts.files.push(p)
continue
}
if (a === '--spec') {
opts.spec = args[++i] || null
if (!opts.spec) die('Missing value for --spec')
continue
}
if (a === '--limit') {
const raw = args[++i]
const n = Number(raw)
if (!Number.isFinite(n) || n <= 0) die(`Invalid --limit: ${raw}`)
opts.limit = n
continue
}
if (a === '--tag') {
opts.tag = args[++i] || null
if (!opts.tag) die('Missing value for --tag')
continue
}
if (a === '--q') {
opts.q = args[++i] || null
if (!opts.q) die('Missing value for --q')
continue
}
if (!command && (a.endsWith('.yaml') || a.endsWith('.yml'))) {
opts.files.push(a)
continue
}
if (!command) command = a
else rest.push(a)
}
return { opts, command, rest }
}
function walk(dir, { filePattern, maxDepth = 10 } = {}, depth = 0, out = []) {
if (depth > maxDepth) return out
let entries
try {
entries = readdirSync(dir)
} catch {
return out
}
for (const entry of entries) {
const full = path.join(dir, entry)
let st
try {
st = statSync(full)
} catch {
continue
}
if (st.isDirectory()) {
walk(full, { filePattern, maxDepth }, depth + 1, out)
} else if (!filePattern || filePattern.test(entry)) {
out.push(full)
}
}
return out
}
function findUpDir(startDir, targetDirName, { maxDepth = 12 } = {}) {
let current = path.resolve(startDir)
for (let i = 0; i <= maxDepth; i++) {
const candidate = path.join(current, targetDirName)
try {
const st = statSync(candidate)
if (st.isDirectory()) return candidate
} catch {
// ignore
}
const parent = path.dirname(current)
if (parent === current) break
current = parent
}
return null
}
function findLocalApiDir() {
return findUpDir(process.cwd(), 'api') || findUpDir(SCRIPT_DIR, 'api')
}
function isUrl(s) {
return /^https?:\/\//i.test(String(s))
}
function deducePublishedOpenApiUrls(whatsappUrl = DEFAULT_PUBLISHED_WHATSAPP_OPENAPI) {
const u = new URL(whatsappUrl)
const origin = u.origin
return [
whatsappUrl,
new URL('/api/platform/v1/openapi-platform.yaml', origin).toString(),
new URL('/api/platform/v1/openapi-workflows.yaml', origin).toString(),
]
}
function inferSpecId(filePath, spec) {
const base = (isUrl(filePath) ? path.basename(new URL(filePath).pathname) : path.basename(filePath)).toLowerCase()
if (base.includes('whatsapp')) return 'whatsapp'
if (base.includes('workflows')) return 'workflows'
if (base.includes('platform')) return 'platform'
const title = (spec?.info?.title || '').toLowerCase()
if (title.includes('whatsapp')) return 'whatsapp'
if (title.includes('workflow')) return 'workflows'
if (title.includes('platform')) return 'platform'
return base.replace(/^openapi-/, '').replace(/\.(ya?ml)$/, '') || 'spec'
}
function decodeJsonPointerToken(token) {
return token.replaceAll('~1', '/').replaceAll('~0', '~')
}
function getByJsonPointer(doc, pointer) {
if (!pointer || !pointer.startsWith('#/')) return null
const parts = pointer.slice(2).split('/').map(decodeJsonPointerToken)
let cur = doc
for (const part of parts) {
if (cur == null) return null
cur = cur[part]
}
return cur ?? null
}
function refName(ref) {
if (!ref) return null
const parts = String(ref).split('/')
return parts[parts.length - 1] || null
}
function derefObject(obj, doc, stack = new Set()) {
if (!obj?.$ref) return obj
const ref = String(obj.$ref)
if (stack.has(ref)) return obj
stack.add(ref)
const resolved = getByJsonPointer(doc, ref)
if (!resolved) return obj
return derefObject(resolved, doc, stack)
}
function schemaTypeString(schema) {
if (!schema) return 'any'
if (schema.$ref) return refName(schema.$ref) || 'ref'
if (schema.const !== undefined) return `const ${JSON.stringify(schema.const)}`
if (schema.enum?.length) {
const vals = schema.enum.slice(0, 6).map((v) => JSON.stringify(v)).join(' | ')
return schema.enum.length > 6 ? `enum(${vals} | ...)` : `enum(${vals})`
}
if (schema.type) {
if (Array.isArray(schema.type)) return schema.type.join('|')
return schema.type
}
if (schema.oneOf) return 'oneOf'
if (schema.anyOf) return 'anyOf'
if (schema.allOf) return 'allOf'
if (schema.properties) return 'object'
if (schema.items) return 'array'
return 'any'
}
function normalizeType(schema) {
if (!schema) return null
const t = schema.type
if (!t) return null
if (Array.isArray(t)) {
const nonNull = t.find((x) => x !== 'null')
return nonNull || t[0] || null
}
return t
}
function mergeAllOf(schema, doc, seen = new Set()) {
if (!schema?.allOf?.length) return schema
const out = { ...schema }
delete out.allOf
let merged = { type: 'object', properties: {}, required: [] }
for (const part of schema.allOf) {
let s = part
if (s?.$ref) {
const name = refName(s.$ref)
if (name) {
if (seen.has(name)) continue
seen.add(name)
}
s = getByJsonPointer(doc, s.$ref) || s
}
if (s?.allOf) s = mergeAllOf(s, doc, seen)
if (s?.type && normalizeType(s) !== 'object' && s.properties) {
// some specs omit type: object but still have properties
} else if (normalizeType(s) && normalizeType(s) !== 'object' && !s.properties) {
return schema
}
if (s?.properties) Object.assign(merged.properties, s.properties)
if (Array.isArray(s?.required)) merged.required.push(...s.required)
if (s?.description && !merged.description) merged.description = s.description
}
merged.required = [...new Set(merged.required)]
return { ...merged, ...out }
}
function formatSchemaPreview(schema, doc, { depth = 2, maxProps = 12 } = {}, indent = '', refStack = new Set()) {
if (!schema) return [`${indent}any`]
if (schema.$ref) {
const name = refName(schema.$ref) || schema.$ref
const resolved = getByJsonPointer(doc, schema.$ref)
const lines = [`${indent}${name}`]
const refKey = String(schema.$ref)
if (refStack.has(refKey)) return lines
refStack.add(refKey)
if (resolved && depth > 0) {
const next = resolved?.allOf ? mergeAllOf(resolved, doc) : resolved
const childLines = formatSchemaPreview(next, doc, { depth, maxProps }, indent + ' ', refStack)
// Avoid printing a single redundant "object" line for refs
if (!(childLines.length === 1 && childLines[0].trim() === 'object')) lines.push(...childLines)
}
refStack.delete(refKey)
return lines
}
const normalized = schema.allOf ? mergeAllOf(schema, doc) : schema
const t = normalizeType(normalized) || schemaTypeString(normalized)
if (t === 'object' || normalized.properties) {
const props = normalized.properties || {}
const required = new Set(normalized.required || [])
const keys = Object.keys(props)
if (!keys.length) return [`${indent}object`]
const lines = []
for (const key of keys.slice(0, maxProps)) {
const s = props[key]
const typeStr = schemaTypeString(s)
const req = required.has(key) ? ' (required)' : ''
const desc = s?.description ? ` - ${String(s.description).replaceAll('\n', ' ').slice(0, 80)}` : ''
lines.push(`${indent}- ${key}: ${typeStr}${req}${desc}`)
if (depth > 0) {
const child = s?.$ref ? getByJsonPointer(doc, s.$ref) : s
const childNorm = child?.allOf ? mergeAllOf(child, doc) : child
const childType = normalizeType(childNorm)
if (childType === 'object' || childNorm?.properties || childNorm?.items) {
const nested = formatSchemaPreview(childNorm, doc, { depth: depth - 1, maxProps }, indent + ' ', refStack)
// Only include nested if it adds something beyond "object"/"array"
if (!(nested.length === 1 && ['object', 'array'].includes(nested[0].trim()))) lines.push(...nested)
}
}
}
if (keys.length > maxProps) lines.push(`${indent}- ... (${keys.length - maxProps} more)`)
return lines
}
if (t === 'array' || normalized.items) {
const itemType = schemaTypeString(normalized.items)
const lines = [`${indent}array<${itemType}>`]
if (normalized.items && depth > 0) {
const next = normalized.items?.$ref ? getByJsonPointer(doc, normalized.items.$ref) : normalized.items
const nextNorm = next?.allOf ? mergeAllOf(next, doc) : next
const nested = formatSchemaPreview(nextNorm, doc, { depth: depth - 1, maxProps }, indent + ' ', refStack)
const nestedTrim = nested.length === 1 ? nested[0].trim() : null
const redundant =
nestedTrim === null ? false : ['object', 'array'].includes(nestedTrim) || nestedTrim === itemType
if (!redundant) lines.push(...nested)
}
return lines
}
if (normalized.oneOf?.length) {
const opts = normalized.oneOf.slice(0, 5).map((s) => schemaTypeString(s)).join(' | ')
return [`${indent}oneOf(${opts}${normalized.oneOf.length > 5 ? ' | ...' : ''})`]
}
if (normalized.anyOf?.length) {
const opts = normalized.anyOf.slice(0, 5).map((s) => schemaTypeString(s)).join(' | ')
return [`${indent}anyOf(${opts}${normalized.anyOf.length > 5 ? ' | ...' : ''})`]
}
return [`${indent}${schemaTypeString(normalized)}`]
}
function placeholderForString(schema) {
if (schema?.enum?.length) return schema.enum[0]
if (schema?.const !== undefined) return schema.const
if (schema?.format === 'uuid') return '00000000-0000-0000-0000-000000000000'
if (schema?.format === 'date-time') return '2025-01-01T00:00:00Z'
if (schema?.format === 'date') return '2025-01-01'
if (schema?.format === 'email') return 'user@example.com'
if (schema?.format === 'uri' || schema?.format === 'url') return 'https://example.com'
return 'string'
}
function exampleFromSchema(schema, doc, { depth = 3, includeOptional = false } = {}, stack = new Set()) {
if (!schema) return null
if (schema.$ref) {
const name = refName(schema.$ref)
if (name) {
if (stack.has(name)) return `<circular:${name}>`
stack.add(name)
}
const resolved = getByJsonPointer(doc, schema.$ref)
const out = exampleFromSchema(resolved || {}, doc, { depth, includeOptional }, stack)
if (name) stack.delete(name)
return out
}
const normalized = schema.allOf ? mergeAllOf(schema, doc) : schema
if (normalized.const !== undefined) return normalized.const
if (normalized.enum?.length) return normalized.enum[0]
const t = normalizeType(normalized)
if (t === 'string') return placeholderForString(normalized)
if (t === 'integer') return 0
if (t === 'number') return 0
if (t === 'boolean') return true
if (t === 'null') return null
if ((t === 'array' || normalized.items) && depth > 0) {
return [exampleFromSchema(normalized.items || {}, doc, { depth: depth - 1, includeOptional }, stack)]
}
if ((t === 'object' || normalized.properties || normalized.additionalProperties) && depth > 0) {
const props = normalized.properties || {}
const required = new Set(normalized.required || [])
const out = {}
const keys = Object.keys(props)
for (const key of keys) {
if (!includeOptional && !required.has(key)) continue
out[key] = exampleFromSchema(props[key], doc, { depth: depth - 1, includeOptional }, stack)
}
// If it is a free-form map, add a placeholder entry.
if (!keys.length && normalized.additionalProperties) {
out.key = exampleFromSchema(
normalized.additionalProperties === true ? {} : normalized.additionalProperties,
doc,
{ depth: depth - 1, includeOptional },
stack,
)
}
return out
}
if (normalized.oneOf?.length) return exampleFromSchema(normalized.oneOf[0], doc, { depth, includeOptional }, stack)
if (normalized.anyOf?.length) return exampleFromSchema(normalized.anyOf[0], doc, { depth, includeOptional }, stack)
return null
}
function collectSchemaRefs(schema, doc, out = new Set(), stack = new Set()) {
if (!schema) return out
if (schema.$ref) {
const name = refName(schema.$ref)
if (name) out.add(name)
if (name) {
if (stack.has(name)) return out
stack.add(name)
}
const resolved = getByJsonPointer(doc, schema.$ref)
if (resolved) collectSchemaRefs(resolved, doc, out, stack)
if (name) stack.delete(name)
return out
}
const normalized = schema.allOf ? mergeAllOf(schema, doc) : schema
for (const key of ['oneOf', 'anyOf', 'allOf']) {
if (Array.isArray(normalized[key])) {
for (const s of normalized[key]) collectSchemaRefs(s, doc, out, stack)
}
}
if (normalized.properties) {
for (const s of Object.values(normalized.properties)) collectSchemaRefs(s, doc, out, stack)
}
if (normalized.items) collectSchemaRefs(normalized.items, doc, out, stack)
if (normalized.additionalProperties && normalized.additionalProperties !== true) {
collectSchemaRefs(normalized.additionalProperties, doc, out, stack)
}
return out
}
function collectSchemaFieldNames(schema, doc, out = new Set(), stack = new Set(), depth = 3) {
if (!schema || depth < 0) return out
if (schema.$ref) {
const ref = String(schema.$ref)
if (stack.has(ref)) return out
stack.add(ref)
const resolved = getByJsonPointer(doc, ref)
if (resolved) collectSchemaFieldNames(resolved, doc, out, stack, depth)
stack.delete(ref)
return out
}
const normalized = schema.allOf ? mergeAllOf(schema, doc) : schema
for (const key of ['oneOf', 'anyOf', 'allOf']) {
if (Array.isArray(normalized[key])) {
for (const s of normalized[key]) collectSchemaFieldNames(s, doc, out, stack, depth)
}
}
if (normalized.properties) {
for (const [k, s] of Object.entries(normalized.properties)) {
out.add(k)
collectSchemaFieldNames(s, doc, out, stack, depth - 1)
}
}
if (normalized.items) collectSchemaFieldNames(normalized.items, doc, out, stack, depth - 1)
if (normalized.additionalProperties && normalized.additionalProperties !== true) {
collectSchemaFieldNames(normalized.additionalProperties, doc, out, stack, depth - 1)
}
return out
}
function normalizeOperationSecurity(op, spec) {
if (Object.prototype.hasOwnProperty.call(op, 'security')) return op.security
return spec.security
}
function securityLabel(opSecurity, spec) {
if (!opSecurity) return null
if (Array.isArray(opSecurity) && opSecurity.length === 0) return 'none'
const schemes = spec?.components?.securitySchemes || {}
function schemeLabel(name) {
const s = schemes[name]
if (!s) return name
if (s.type === 'apiKey' && s.in === 'header' && s.name) return `${name} (${s.name} header)`
if (s.type === 'apiKey' && s.in) return `${name} (apiKey in ${s.in})`
if (s.type === 'http' && s.scheme === 'bearer') return `${name} (Authorization: Bearer ...)`
if (s.type === 'http') return `${name} (http ${s.scheme || ''})`.trim()
return `${name} (${s.type})`
}
const requirementSets = []
for (const req of opSecurity || []) {
const keys = Object.keys(req || {})
if (!keys.length) continue
requirementSets.push(keys.map(schemeLabel).join(' + '))
}
if (!requirementSets.length) return null
return requirementSets.join(' OR ')
}
function toShortSpec(s) {
return {
id: s.id,
title: s.title,
version: s.version,
file: s.filePath,
servers: s.servers,
operations: s.operations.length,
schemas: Object.keys(s.schemas).length,
}
}
async function readSpecSource(source) {
if (isUrl(source)) {
const res = await fetch(source, {
// Some CDNs / WAFs behave better with a UA.
headers: { 'User-Agent': 'kapso-openapi-explore/1.0 (+https://docs.kapso.ai)' },
})
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`)
return await res.text()
}
return readFileSync(source, 'utf-8')
}
async function loadSpecsAsync({ sources, sourceMode, specFilter }) {
if (!sources.length) die('No OpenAPI sources provided')
const seenIds = new Map()
const specs = []
const errors = []
for (const filePath of sources) {
let raw = null
try {
raw = await readSpecSource(filePath)
} catch (e) {
errors.push({ source: filePath, error: e })
if (sourceMode === 'explicit') console.error(`Skipping ${filePath}: ${e.message}`)
continue
}
let doc
try {
doc = parseYaml(raw)
} catch (e) {
errors.push({ source: filePath, error: e })
console.error(`Skipping ${filePath}: YAML parse error: ${e.message}`)
continue
}
const idBase = inferSpecId(filePath, doc)
const n = (seenIds.get(idBase) || 0) + 1
seenIds.set(idBase, n)
const id = n === 1 ? idBase : `${idBase}${n}`
if (specFilter && id !== specFilter) continue
const title = doc?.info?.title || id
const version = doc?.info?.version || '?'
const servers = (doc?.servers || []).map((s) => s?.url).filter(Boolean)
const tags = doc?.tags || []
const schemas = doc?.components?.schemas || {}
const schemaFieldCache = new Map()
const getSchemaFields = (schemaName) => {
if (schemaFieldCache.has(schemaName)) return schemaFieldCache.get(schemaName)
const schema = schemas?.[schemaName]
const fields = schema ? [...collectSchemaFieldNames(schema, doc)] : []
schemaFieldCache.set(schemaName, fields)
return fields
}
const operations = []
const paths = doc?.paths || {}
for (const [p, pathItemRaw] of Object.entries(paths)) {
const pathItem = pathItemRaw || {}
const pathParams = Array.isArray(pathItem.parameters) ? pathItem.parameters.map((x) => derefObject(x, doc)) : []
for (const method of HTTP_METHODS) {
const op = pathItem[method]
if (!op) continue
const mergedParams = [...pathParams, ...((op.parameters || []).filter(Boolean))]
.map((x) => derefObject(x, doc))
.filter(Boolean)
const opSecurity = normalizeOperationSecurity(op, doc)
const opRequestBody = derefObject(op.requestBody || null, doc)
const refs = new Set()
// params
for (const param of mergedParams) {
if (param?.schema) collectSchemaRefs(param.schema, doc, refs)
if (param?.content) {
for (const media of Object.values(param.content)) {
if (media?.schema) collectSchemaRefs(media.schema, doc, refs)
}
}
}
// body
if (opRequestBody?.content) {
for (const media of Object.values(opRequestBody.content)) {
if (media?.schema) collectSchemaRefs(media.schema, doc, refs)
}
}
// responses
if (op?.responses) {
for (const resp of Object.values(op.responses)) {
const r = derefObject(resp, doc)
if (r?.content) {
for (const media of Object.values(r.content)) {
if (media?.schema) collectSchemaRefs(media.schema, doc, refs)
}
}
}
}
const fieldsUsed = new Set()
for (const schemaName of refs) {
for (const f of getSchemaFields(schemaName)) fieldsUsed.add(f)
}
operations.push({
specId: id,
specTitle: title,
specFile: filePath,
method,
path: p,
operationId: op.operationId || null,
summary: op.summary || null,
description: op.description || null,
tags: op.tags || [],
deprecated: Boolean(op.deprecated),
security: opSecurity,
securityLabel: securityLabel(opSecurity, doc),
parameters: mergedParams,
requestBody: opRequestBody,
responses: op.responses || {},
refsUsed: refs,
fieldsUsed,
})
}
}
specs.push({
id,
filePath,
title,
version,
servers,
doc,
tags,
schemas,
operations,
})
}
if (!specs.length) {
if (sourceMode === 'default_remote' && errors.length) {
const apiDir = findLocalApiDir()
const local = apiDir ? walk(apiDir, { filePattern: /^openapi-.*\.ya?ml$/ }) : []
if (local.length) {
console.error(`Could not fetch published OpenAPI specs. Falling back to local repo specs (api/**/openapi-*.yaml).`)
return await loadSpecsAsync({ sources: local, sourceMode: 'explicit', specFilter })
}
}
if (specFilter) die(`No specs matched --spec ${specFilter}`)
die('No specs loaded')
}
if (sourceMode === 'default_remote' && errors.length) {
console.error(`Some published OpenAPI specs could not be fetched:`)
for (const e of errors) console.error(`- ${e.source}: ${e.error?.message || String(e.error)}`)
}
return specs
}
function findOperation(specs, query, { specHint } = {}) {
const raw = query.trim()
const direct = raw.includes(':') ? raw.split(':') : null
const qSpec = direct?.[0] || specHint
const qId = direct?.[1] || raw
// METHOD /path
const m = raw.match(/^(get|post|put|patch|delete|options|head)\\s+(.+)$/i)
if (m) {
const method = m[1].toLowerCase()
const p = m[2].trim()
const matches = []
for (const s of specs) {
if (qSpec && s.id !== qSpec) continue
for (const op of s.operations) {
if (op.method === method && op.path === p) matches.push(op)
}
}
return matches
}
// operationId
const matches = []
for (const s of specs) {
if (qSpec && s.id !== qSpec) continue
for (const op of s.operations) {
if (op.operationId === qId) matches.push(op)
}
}
return matches
}
function findSchema(specs, query, { specHint } = {}) {
const raw = query.trim()
const direct = raw.includes(':') ? raw.split(':') : null
const qSpec = direct?.[0] || specHint
const qName = direct?.[1] || raw
const matches = []
for (const s of specs) {
if (qSpec && s.id !== qSpec) continue
if (Object.prototype.hasOwnProperty.call(s.schemas, qName)) {
matches.push({ spec: s, name: qName, schema: s.schemas[qName] })
}
}
return matches
}
function scoreText(hay, q) {
if (!hay) return 0
const h = hay.toLowerCase()
if (h === q) return 100
if (h.startsWith(q)) return 60
if (h.includes(q)) return 30
return 0
}
function search(specs, query, { specHint, limit = 30 } = {}) {
const q = query.toLowerCase().trim()
if (!q) return { ops: [], schemas: [] }
const ops = []
const schemas = []
for (const s of specs) {
if (specHint && s.id !== specHint) continue
for (const op of s.operations) {
let score = 0
score += scoreText(op.operationId, q) * 3
score += scoreText(`${op.method} ${op.path}`, q) * 2
score += scoreText(op.summary, q) * 2
score += scoreText(op.description, q)
for (const t of op.tags || []) score += scoreText(t, q) * 2
score += scoreText((op.parameters || []).map((p) => p?.name).filter(Boolean).join(' '), q) * 2
score += scoreText([...op.refsUsed].join(' '), q) * 2
score += scoreText([...op.fieldsUsed].join(' '), q)
if (score > 0) ops.push({ score, op })
}
for (const [name, schema] of Object.entries(s.schemas)) {
let score = 0
score += scoreText(name, q) * 3
score += scoreText(schema?.description, q)
score += scoreText([...collectSchemaFieldNames(schema, s.doc)].join(' '), q) * 2
if (score > 0) schemas.push({ score, spec: s, name, schema })
}
}
ops.sort((a, b) => b.score - a.score)
schemas.sort((a, b) => b.score - a.score)
return {
ops: ops.slice(0, limit).map((x) => x.op),
schemas: schemas.slice(0, limit).map((x) => ({ specId: x.spec.id, name: x.name })),
}
}
function printTable(rows, { sep = ' ' } = {}) {
if (!rows.length) return
const widths = []
for (const row of rows) {
row.forEach((cell, i) => {
widths[i] = Math.max(widths[i] || 0, String(cell).length)
})
}
for (const row of rows) {
const line = row
.map((cell, i) => String(cell).padEnd(widths[i], ' '))
.join(sep)
.trimEnd()
console.log(line)
}
}
function printSpecs(specs, { json } = {}) {
if (json) {
console.log(JSON.stringify(specs.map(toShortSpec), null, 2))
return
}
const rows = [['id', 'version', 'operations', 'schemas', 'file']]
for (const s of specs) rows.push([s.id, s.version, String(s.operations.length), String(Object.keys(s.schemas).length), s.filePath])
printTable(rows)
}
function printTags(specs, { specHint } = {}) {
const rows = [['spec', 'tag', 'description']]
for (const s of specs) {
if (specHint && s.id !== specHint) continue
for (const t of s.tags || []) rows.push([s.id, t.name, (t.description || '').replaceAll('\n', ' ').slice(0, 80)])
}
if (rows.length === 1) die('No tags found')
printTable(rows)
}
function printOps(specs, { specHint, tag, q, json, limit = 30 } = {}) {
let ops = []
for (const s of specs) {
if (specHint && s.id !== specHint) continue
ops.push(...s.operations)
}
if (tag) ops = ops.filter((o) => (o.tags || []).includes(tag))
if (q) {
const res = search(specs, q, { specHint, limit: Math.max(limit, 200) })
const opSet = new Set(res.ops.map((o) => `${o.specId}:${o.operationId || o.method + ' ' + o.path}`))
ops = ops.filter((o) => opSet.has(`${o.specId}:${o.operationId || o.method + ' ' + o.path}`))
}
ops = ops.slice(0, limit)
if (json) {
console.log(
JSON.stringify(
ops.map((o) => ({
spec: o.specId,
operationId: o.operationId,
method: o.method,
path: o.path,
summary: o.summary,
})),
null,
2,
),
)
return
}
const rows = [['spec', 'operationId', 'method', 'path', 'summary']]
for (const o of ops) rows.push([o.specId, o.operationId || '-', o.method.toUpperCase(), o.path, (o.summary || '').slice(0, 80)])
printTable(rows)
}
function splitParams(params) {
const out = { path: [], query: [], header: [], cookie: [], other: [] }
for (const p of params || []) {
const loc = p?.in
if (loc === 'path') out.path.push(p)
else if (loc === 'query') out.query.push(p)
else if (loc === 'header') out.header.push(p)
else if (loc === 'cookie') out.cookie.push(p)
else out.other.push(p)
}
return out
}
function paramType(param) {
const s = param?.schema
if (s) return schemaTypeString(s)
if (param?.content) {
const media = Object.values(param.content)[0]
if (media?.schema) return schemaTypeString(media.schema)
}
return 'any'
}
function printParamsBlock(title, params) {
if (!params.length) return
console.log(`\n${title}`)
const rows = [['name', 'in', 'required', 'type', 'description']]
for (const p of params) {
rows.push([
p.name || '-',
p.in || '-',
p.required ? 'yes' : 'no',
paramType(p),
(p.description || '').replaceAll('\n', ' ').slice(0, 80),
])
}
printTable(rows)
}
function printOperation(op, spec) {
const baseUrl = spec.servers?.[0] || ''
const full = baseUrl ? `${baseUrl}${op.path}` : op.path
const id = op.operationId ? `${op.specId}:${op.operationId}` : `${op.specId}:${op.method.toUpperCase()} ${op.path}`
console.log(id)
console.log(`${op.method.toUpperCase()} ${full}`)
if (op.summary) console.log(`\n${op.summary}`)
if (op.deprecated) console.log(`\nDeprecated: yes`)
const auth = op.securityLabel
if (auth) console.log(`\nAuth: ${auth}`)
if (op.tags?.length) console.log(`Tags: ${op.tags.join(', ')}`)
if (op.description) {
const desc = String(op.description).trim().replace(/\n{3,}/g, '\n\n')
const short = desc.length > 800 ? `${desc.slice(0, 800)}\n...` : desc
console.log(`\n${short}`)
}
const params = splitParams(op.parameters || [])
printParamsBlock('Path params', params.path)
printParamsBlock('Query params', params.query)
printParamsBlock('Header params', params.header)
if (op.requestBody?.content) {
console.log('\nBody')
const required = op.requestBody.required ? 'required' : 'optional'
console.log(`required: ${required}`)
const contentTypes = Object.keys(op.requestBody.content)
for (const ct of contentTypes) {
const schema = op.requestBody.content?.[ct]?.schema
if (!schema) continue
console.log(`\n${ct}`)
const lines = formatSchemaPreview(schema, spec.doc, { depth: 3, maxProps: 12 })
for (const l of lines) console.log(l)
if (ct === 'application/json') {
const ex = exampleFromSchema(schema, spec.doc, { depth: 3, includeOptional: false })
if (ex && typeof ex === 'object') {
console.log('\nexample (required fields)')
console.log(JSON.stringify(ex, null, 2))
}
}
}
}
if (op.responses && Object.keys(op.responses).length) {
console.log('\nResponses')
const codes = Object.keys(op.responses).sort((a, b) => {
const na = Number(a)
const nb = Number(b)
if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb
if (a === 'default') return 1
if (b === 'default') return -1
return a.localeCompare(b)
})
for (const code of codes) {
const resp = derefObject(op.responses[code], spec.doc)
const desc = resp?.description ? ` - ${String(resp.description).replaceAll('\n', ' ').slice(0, 80)}` : ''
console.log(`\n${code}${desc}`)
const content = resp?.content || {}
for (const [ct, media] of Object.entries(content)) {
if (!media?.schema) continue
console.log(`${ct}`)
const lines = formatSchemaPreview(media.schema, spec.doc, { depth: 3, maxProps: 12 })
for (const l of lines) console.log(l)
}
}
}
console.log('\ncurl')
console.log(formatCurl(op, spec))
}
function replacePathParams(p) {
return p.replaceAll(/\{([^}]+)\}/g, '<$1>')
}
function formatCurl(op, spec) {
const baseUrl = spec.servers?.[0] || ''
let url = baseUrl ? `${baseUrl}${replacePathParams(op.path)}` : replacePathParams(op.path)
const requiredQuery = (op.parameters || [])
.filter((p) => p?.in === 'query' && p?.required && p?.name)
.map((p) => `${encodeURIComponent(p.name)}=<${p.name}>`)
if (requiredQuery.length) url += `?${requiredQuery.join('&')}`
const lines = []
lines.push(`curl -X ${op.method.toUpperCase()} '${url}' \\`)
// auth header if we can infer it
const schemes = spec?.doc?.components?.securitySchemes || {}
const sec = Array.isArray(op.security) ? op.security : null
const candidates = (sec || []).filter((x) => x && typeof x === 'object')
const preferred =
candidates.find((req) =>
Object.keys(req).some((name) => schemes[name]?.type === 'apiKey' && schemes[name]?.in === 'header'),
) || candidates[0]
for (const name of Object.keys(preferred || {})) {
const s = schemes[name]
if (s?.type === 'apiKey' && s.in === 'header' && s.name) {
lines.push(` -H '${s.name}: $KAPSO_API_KEY' \\`)
} else if (s?.type === 'http' && s.scheme === 'bearer') {
lines.push(` -H 'Authorization: Bearer $ACCESS_TOKEN' \\`)
}
}
// content-type + body for JSON
const jsonSchema = op.requestBody?.content?.['application/json']?.schema
if (jsonSchema) {
const ex = exampleFromSchema(jsonSchema, spec.doc, { depth: 4, includeOptional: false })
lines.push(` -H 'Content-Type: application/json' \\`)
lines.push(` -d '${JSON.stringify(ex ?? {}, null, 0)}'`)
return lines.join('\n')
}
// trim trailing backslash
lines[lines.length - 1] = lines[lines.length - 1].replace(/ \\\\$/, '')
return lines.join('\n')
}
function printSchemaDetails(match, specs, { includeUsedBy = true } = {}) {
const { spec, name, schema } = match
console.log(`${spec.id}:${name}`)
const normalized = schema?.allOf ? mergeAllOf(schema, spec.doc) : schema
const typeStr = schemaTypeString(normalized)
console.log(typeStr)
if (normalized?.description) console.log(`\n${String(normalized.description).trim()}`)
const required = normalized?.required || []
if (required.length) console.log(`\nrequired: ${required.join(', ')}`)
if (normalized?.properties || normalized?.items) {
console.log('\nshape')
const lines = formatSchemaPreview(normalized, spec.doc, { depth: 3, maxProps: 25 })
for (const l of lines) console.log(l)
}
const ex = exampleFromSchema(normalized, spec.doc, { depth: 4, includeOptional: false })
if (ex && typeof ex === 'object') {
console.log('\nexample (required fields)')
console.log(JSON.stringify(ex, null, 2))
}
if (includeUsedBy) {
const usedBy = []
for (const s of specs) {
for (const op of s.operations) {
if (op.refsUsed?.has(name)) usedBy.push(op)
}
}
if (usedBy.length) {
console.log('\nused by')
const rows = [['spec', 'operationId', 'method', 'path']]
for (const op of usedBy.slice(0, 30)) rows.push([op.specId, op.operationId || '-', op.method.toUpperCase(), op.path])
printTable(rows)
if (usedBy.length > 30) console.log(`... (${usedBy.length - 30} more)`)
}
}
}
function printWhere(specs, schemaQuery, { specHint, json, limit = 30 } = {}) {
const matches = findSchema(specs, schemaQuery, { specHint })
if (!matches.length) die(`Schema not found: ${schemaQuery}`)
if (matches.length > 1 && !schemaQuery.includes(':')) {
die(`Schema exists in multiple specs. Use specId:SchemaName\n${matches.map((m) => `- ${m.spec.id}:${m.name}`).join('\n')}`)
}
const { name } = matches[0]
const usedBy = []
for (const s of specs) {
if (specHint && s.id !== specHint) continue
for (const op of s.operations) {
if (op.refsUsed?.has(name)) usedBy.push(op)
}
}
if (json) {
console.log(
JSON.stringify(
usedBy.slice(0, limit).map((o) => ({
spec: o.specId,
operationId: o.operationId,
method: o.method,
path: o.path,
summary: o.summary,
})),
null,
2,
),
)
return
}
if (!usedBy.length) {
console.log('No operations reference this schema (via $ref).')
return
}
const rows = [['spec', 'operationId', 'method', 'path', 'summary']]
for (const o of usedBy.slice(0, limit)) rows.push([o.specId, o.operationId || '-', o.method.toUpperCase(), o.path, (o.summary || '').slice(0, 80)])
printTable(rows)
if (usedBy.length > limit) console.log(`... (${usedBy.length - limit} more)`)
}
async function main() {
const { opts, command, rest } = parseArgs(process.argv.slice(2))
if (!command) printHelp(1)
let sources = []
let sourceMode = 'explicit'
if (opts.files.length) {
sources = opts.files
sourceMode = 'explicit'
} else if (opts.all) {
const apiDir = findLocalApiDir()
sources = apiDir ? walk(apiDir, { filePattern: /^openapi-.*\.ya?ml$/ }) : []
sourceMode = 'explicit'
} else {
sources = deducePublishedOpenApiUrls(DEFAULT_PUBLISHED_WHATSAPP_OPENAPI)
sourceMode = 'default_remote'
}
const specs = await loadSpecsAsync({ sources, sourceMode, specFilter: opts.spec })
if (command === 'specs') return printSpecs(specs, { json: opts.json })
if (command === 'tags') return printTags(specs, { specHint: opts.spec })
if (command === 'ops') return printOps(specs, { specHint: opts.spec, tag: opts.tag, q: opts.q, json: opts.json, limit: opts.limit })
if (command === 'search') {
const q = rest.join(' ').trim()
if (!q) die('Usage: search <query>')
const res = search(specs, q, { specHint: opts.spec, limit: opts.limit })
if (opts.json) {
return console.log(
JSON.stringify(
{
ops: res.ops.map((o) => ({
spec: o.specId,
operationId: o.operationId,
method: o.method,
path: o.path,
summary: o.summary,
})),
schemas: res.schemas,
},
null,
2,
),
)
}
if (!res.ops.length && !res.schemas.length) {
console.log('No matches.')
return
}
if (res.ops.length) {
console.log('ops')
const rows = [['spec', 'operationId', 'method', 'path', 'summary']]
for (const o of res.ops) rows.push([o.specId, o.operationId || '-', o.method.toUpperCase(), o.path, (o.summary || '').slice(0, 80)])
printTable(rows)
}
if (res.schemas.length) {
console.log('\nschemas')
const rows = [['spec', 'name']]
for (const s of res.schemas) rows.push([s.specId, s.name])
printTable(rows)
}
return
}
if (command === 'op') {
const q = rest.join(' ').trim()
if (!q) die('Usage: op <operationId | specId:operationId | "METHOD /path">')
const matches = findOperation(specs, q, { specHint: opts.spec })
if (!matches.length) {
const res = search(specs, q, { specHint: opts.spec, limit: 10 })
if (res.ops.length) {
console.log(`Operation not found: ${q}\n`)
console.log('closest ops')
const rows = [['spec', 'operationId', 'method', 'path', 'summary']]
for (const o of res.ops) rows.push([o.specId, o.operationId || '-', o.method.toUpperCase(), o.path, (o.summary || '').slice(0, 80)])
printTable(rows)
process.exit(1)
}
die(`Operation not found: ${q}`)
}
if (matches.length > 1 && !q.includes(':') && !q.match(/^(get|post|put|patch|delete|options|head)\\s+/i)) {
die(`OperationId exists in multiple specs. Use specId:operationId\n${matches.map((m) => `- ${m.specId}:${m.operationId}`).join('\n')}`)
}
const op = matches[0]
const spec = specs.find((s) => s.id === op.specId)
if (!spec) die(`Internal error: missing spec ${op.specId}`)
printOperation(op, spec)
return
}
if (command === 'schema') {
const q = rest.join(' ').trim()
if (!q) die('Usage: schema <SchemaName | specId:SchemaName>')
const matches = findSchema(specs, q, { specHint: opts.spec })
if (!matches.length) {
const res = search(specs, q, { specHint: opts.spec, limit: 10 })
if (res.schemas.length) {
console.log(`Schema not found: ${q}\n`)
console.log('closest schemas')
const rows = [['spec', 'name']]
for (const s of res.schemas) rows.push([s.specId, s.name])
printTable(rows)
process.exit(1)
}
die(`Schema not found: ${q}`)
}
if (matches.length > 1 && !q.includes(':')) {
die(`Schema exists in multiple specs. Use specId:SchemaName\n${matches.map((m) => `- ${m.spec.id}:${m.name}`).join('\n')}`)
}
printSchemaDetails(matches[0], specs, { includeUsedBy: true })
return
}
if (command === 'where') {
const q = rest.join(' ').trim()
if (!q) die('Usage: where <SchemaName | specId:SchemaName>')
return printWhere(specs, q, { specHint: opts.spec, json: opts.json, limit: opts.limit })
}
die(`Unknown command: ${command}`)
}
main().catch((e) => die(e?.stack || e?.message || String(e)))
scripts/project-event-definitions.js
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag } from './lib/workflows/args.js';
function usage() {
return ok({
usage: [
'node scripts/project-event-definitions.js list',
'node scripts/project-event-definitions.js create --name <event.name> [--description <text>] [--property-schema <json>|--property-schema-file <path>]',
'node scripts/project-event-definitions.js update --definition-id <id> [--name <event.name>] [--description <text>] [--property-schema <json>|--property-schema-file <path>]'
],
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
function readJsonFlag(parsed, flagName, fileFlagName) {
const inline = getFlag(parsed.flags, flagName);
const filePath = getFlag(parsed.flags, fileFlagName);
if (inline && filePath) {
throw new Error(`Use either --${flagName} or --${fileFlagName}, not both`);
}
if (!inline && !filePath) return undefined;
const raw = inline || readFileSync(filePath, 'utf8');
return JSON.parse(raw);
}
function definitionPayload(parsed, { requireName = false } = {}) {
const name = getFlag(parsed.flags, 'name');
if (requireName && !name) {
throw new Error('name is required');
}
const payload = {};
if (name) payload.name = name;
const description = getFlag(parsed.flags, 'description');
if (description !== undefined) payload.description = description;
const propertySchema = readJsonFlag(parsed, 'property-schema', 'property-schema-file');
if (propertySchema !== undefined) payload.property_schema = propertySchema;
return payload;
}
async function listDefinitions(config) {
return requestJson(config, {
method: 'GET',
path: '/platform/v1/event-definitions'
});
}
async function createDefinition(config, parsed) {
return requestJson(config, {
method: 'POST',
path: '/platform/v1/event-definitions',
body: definitionPayload(parsed, { requireName: true })
});
}
async function updateDefinition(config, parsed) {
const definitionId = getFlag(parsed.flags, 'definition-id');
if (!definitionId) throw new Error('definition-id is required');
return requestJson(config, {
method: 'PATCH',
path: `/platform/v1/event-definitions/${definitionId}`,
body: definitionPayload(parsed)
});
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const action = parsed.args[0];
if (!action) {
printJson(usage());
return 2;
}
const config = loadConfig();
let response;
try {
if (action === 'list') {
response = await listDefinitions(config);
} else if (action === 'create') {
response = await createDefinition(config, parsed);
} else if (action === 'update') {
response = await updateDefinition(config, parsed);
} else {
printJson(err(`Invalid action: ${action}. Use list, create, or update`));
return 2;
}
} catch (error) {
printJson(err('Invalid Project Event definition request', { message: String(error?.message || error) }));
return 2;
}
if (!response.ok) {
printJson(err(`Failed to ${action} Project Event definitions`, response.raw, false, response.status));
return 2;
}
printJson(ok({ definitions: response.data }));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/resume-execution.js
#!/usr/bin/env node
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag } from './lib/workflows/args.js';
function usage() {
return ok({
usage: 'node scripts/resume-execution.js <execution-id> --message <json> [--variables <json>]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY'],
examples: [
'node scripts/resume-execution.js exec_123 --message \'{"data":{"text":"hi"}}\''
]
});
}
function parseJson(value, label) {
if (!value) return undefined;
try {
return JSON.parse(value);
} catch (error) {
throw new Error(`Invalid JSON for ${label}: ${String(error?.message || error)}`);
}
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const executionId = parsed.args[0] || getFlag(parsed.flags, 'execution-id');
if (!executionId) {
printJson(err('execution_id is required'));
return 2;
}
let message;
let variables;
try {
message = parseJson(getFlag(parsed.flags, 'message'), 'message');
variables = parseJson(getFlag(parsed.flags, 'variables'), 'variables');
} catch (error) {
printJson(err('Failed to parse JSON', { message: error.message }));
return 2;
}
if (!message || typeof message !== 'object') {
printJson(err('message is required and must be a JSON object'));
return 2;
}
const body = { message };
if (variables) body.variables = variables;
const config = loadConfig();
const response = await requestJson(config, {
method: 'POST',
path: `/platform/v1/workflow_executions/${executionId}/resume`,
body
});
if (!response.ok) {
printJson(err('Failed to resume execution', response.raw, false, response.status));
return 2;
}
printJson(ok({ execution: response.data }));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/update-execution-status.js
#!/usr/bin/env node
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag } from './lib/workflows/args.js';
function usage() {
return ok({
usage: 'node scripts/update-execution-status.js <execution-id> --status <ended|handoff|waiting>',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const executionId = parsed.args[0] || getFlag(parsed.flags, 'execution-id');
if (!executionId) {
printJson(err('execution_id is required'));
return 2;
}
const status = getFlag(parsed.flags, 'status');
if (!status) {
printJson(err('status is required'));
return 2;
}
const config = loadConfig();
const response = await requestJson(config, {
method: 'PATCH',
path: `/platform/v1/workflow_executions/${executionId}`,
body: { workflow_execution: { status } }
});
if (!response.ok) {
printJson(err('Failed to update execution status', response.raw, false, response.status));
return 2;
}
printJson(ok({ execution: response.data }));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/update-function.js
import { readFileSync } from 'node:fs';
import { kapsoConfigFromEnv, kapsoRequest } from './lib/functions/kapso-api.js';
import {
hasHelpFlag,
parseBooleanFlag,
parseEnumFlag,
parseFlags,
requireFlag
} from './lib/functions/args.js';
function ok(data) {
return { ok: true, data };
}
function err(message, details) {
return { ok: false, error: { message, details } };
}
function resolveCode(flags) {
if (typeof flags.code === 'string' && flags.code.length > 0) {
return flags.code;
}
if (typeof flags['code-file'] === 'string' && flags['code-file'].length > 0) {
return readFileSync(flags['code-file'], 'utf8');
}
throw new Error('Provide --code or --code-file');
}
async function main() {
const argv = process.argv.slice(2);
if (hasHelpFlag(argv)) {
console.log(
JSON.stringify(
{
ok: true,
usage:
'node scripts/update-function.js --function-id <id> --name <name> (--code <js> | --code-file <path>) [--description <text>] [--public-endpoint <true|false>] [--invoke-response-mode passthrough|wrapped]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
},
null,
2
)
);
return 0;
}
try {
const flags = parseFlags(argv);
const functionId = requireFlag(flags, 'function-id');
const name = requireFlag(flags, 'name');
const code = resolveCode(flags);
const payload = { name, code };
const publicEndpoint = parseBooleanFlag(flags, 'public-endpoint');
const invokeResponseMode = parseEnumFlag(flags, 'invoke-response-mode', ['passthrough', 'wrapped']);
if (typeof flags.description === 'string' && flags.description.length > 0) {
payload.description = flags.description;
}
if (publicEndpoint !== undefined) {
payload.public_endpoint = publicEndpoint;
}
if (invokeResponseMode !== undefined) {
payload.invoke_response_mode = invokeResponseMode;
}
const config = kapsoConfigFromEnv();
const data = await kapsoRequest(config, `/platform/v1/functions/${encodeURIComponent(functionId)}`, {
method: 'PATCH',
body: JSON.stringify({ function: payload })
});
console.log(JSON.stringify(ok(data), null, 2));
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(JSON.stringify(err('Command failed', { message }), null, 2));
return 1;
}
}
main().then((code) => process.exit(code));
scripts/update-graph.js
#!/usr/bin/env node
import { createHash } from 'crypto';
import { readFileSync } from 'fs';
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag, getNumberFlag } from './lib/workflows/args.js';
function sha256(text) {
return createHash('sha256').update(text).digest('hex');
}
function readFileText(path) {
return readFileSync(path, 'utf8');
}
function normalizeDefinition(input) {
if (!input || typeof input !== 'object') return null;
const record = input;
if (record.ok === true && record.data && typeof record.data === 'object') {
return normalizeDefinition(record.data);
}
if (record.definition && typeof record.definition === 'object') {
return record.definition;
}
if (record.flow && typeof record.flow === 'object') {
const flow = record.flow;
if (flow.definition && typeof flow.definition === 'object') {
return flow.definition;
}
}
if (record.workflow && typeof record.workflow === 'object') {
const workflow = record.workflow;
if (workflow.definition && typeof workflow.definition === 'object') {
return workflow.definition;
}
}
if (record.nodes && record.edges) {
return record;
}
return null;
}
function parseDefinitionInput(raw) {
try {
const parsed = JSON.parse(raw);
return normalizeDefinition(parsed);
} catch {
return null;
}
}
function usage() {
return ok({
usage: 'node scripts/update-graph.js <workflow-id> --expected-lock-version <n> --definition-file <path>|--definition-json <json>',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const workflowId = parsed.args[0] || getFlag(parsed.flags, 'workflow-id');
if (!workflowId) {
printJson(err('workflow_id is required'));
return 2;
}
const expectedLockVersion = getNumberFlag(parsed.flags, 'expected-lock-version')
?? getNumberFlag(parsed.flags, 'lock-version');
if (expectedLockVersion === undefined) {
printJson(err('expected-lock-version is required'));
return 2;
}
const definitionFile = getFlag(parsed.flags, 'definition-file');
const definitionJson = getFlag(parsed.flags, 'definition-json');
if (!definitionFile && !definitionJson) {
printJson(err('definition-file or definition-json is required'));
return 2;
}
const rawDefinition = definitionFile ? readFileText(definitionFile) : definitionJson || '';
const definition = parseDefinitionInput(rawDefinition);
if (!definition) {
const source = definitionFile ? 'definition-file' : 'definition-json';
printJson(err(`Unable to parse workflow definition from ${source}`));
return 2;
}
const config = loadConfig();
const current = await requestJson(config, {
method: 'GET',
path: `/platform/v1/workflows/${workflowId}`
});
if (!current.ok) {
printJson(err('Failed to fetch workflow metadata for lock check', current.raw, false, current.status));
return 2;
}
const currentLock = current.data.lock_version;
if (currentLock !== expectedLockVersion) {
printJson(err('Conflict: workflow was modified. Refetch and retry.', {
expected_lock_version: expectedLockVersion,
current_lock_version: currentLock
}));
return 2;
}
const update = await requestJson(config, {
method: 'PATCH',
path: `/platform/v1/workflows/${workflowId}`,
body: {
workflow: {
definition
}
}
});
if (!update.ok) {
printJson(err('Failed to update workflow definition', update.raw, false, update.status));
return 2;
}
const pretty = JSON.stringify(definition, null, 2);
printJson(ok({
workflow: {
id: update.data.id,
name: update.data.name,
status: update.data.status,
lock_version: update.data.lock_version,
updated_at: update.data.updated_at
},
workflow_graph_sha256: sha256(pretty)
}));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/update-trigger.js
#!/usr/bin/env node
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag } from './lib/workflows/args.js';
function usage() {
return ok({
usage: 'node scripts/update-trigger.js --trigger-id <id> --active true|false',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
function parseBoolean(value) {
if (value === undefined) return undefined;
if (value === true) return true;
const lowered = String(value).toLowerCase();
if (lowered === 'true') return true;
if (lowered === 'false') return false;
return undefined;
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const triggerId = getFlag(parsed.flags, 'trigger-id');
if (!triggerId) {
printJson(err('trigger-id is required'));
return 2;
}
const active = parseBoolean(getFlag(parsed.flags, 'active'));
if (active === undefined) {
printJson(err('active is required (true or false)'));
return 2;
}
const config = loadConfig();
const response = await requestJson(config, {
method: 'PATCH',
path: `/platform/v1/triggers/${triggerId}`,
body: { trigger: { active } }
});
if (!response.ok) {
printJson(err('Failed to update trigger', response.raw, false, response.status));
return 2;
}
printJson(ok({ trigger: response.data }));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/update-workflow-settings.js
#!/usr/bin/env node
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag, getNumberFlag } from './lib/workflows/args.js';
function usage() {
return ok({
usage: 'node scripts/update-workflow-settings.js <workflow-id> --lock-version <n> [--name <name>] [--description <text>] [--status <draft|active|archived>] [--message-debounce-seconds <n>] [--inbound-message-read-mode <disabled|read_only|read_with_typing>]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const workflowId = parsed.args[0] || getFlag(parsed.flags, 'workflow-id');
if (!workflowId) {
printJson(err('workflow_id is required'));
return 2;
}
const lockVersion = getNumberFlag(parsed.flags, 'lock-version');
if (lockVersion === undefined) {
printJson(err('lock-version is required'));
return 2;
}
const payload = {
workflow: {
lock_version: lockVersion
}
};
const name = getFlag(parsed.flags, 'name');
const description = getFlag(parsed.flags, 'description');
const status = getFlag(parsed.flags, 'status');
const messageDebounce = getNumberFlag(parsed.flags, 'message-debounce-seconds');
const inboundMessageReadMode = getFlag(parsed.flags, 'inbound-message-read-mode');
if (name) payload.workflow.name = name;
if (description) payload.workflow.description = description;
if (status) payload.workflow.status = status;
if (messageDebounce !== undefined) payload.workflow.message_debounce_seconds = messageDebounce;
if (inboundMessageReadMode) payload.workflow.inbound_message_read_mode = inboundMessageReadMode;
const config = loadConfig();
const response = await requestJson(config, {
method: 'PATCH',
path: `/platform/v1/workflows/${workflowId}`,
body: payload
});
if (!response.ok) {
printJson(err('Failed to update workflow', response.raw, false, response.status));
return 2;
}
printJson(ok({ workflow: response.data }));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/validate-graph.js
#!/usr/bin/env node
import { readFileSync } from 'fs';
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag } from './lib/workflows/args.js';
const SUPPORTED_NODE_TYPES = new Set([
'start',
'send_text',
'send_template',
'set_variable',
'send_interactive',
'wait_for_response',
'decide',
'call',
'webhook',
'function',
'agent',
'handoff'
]);
function readFileText(path) {
return readFileSync(path, 'utf8');
}
function normalizeDefinition(input) {
if (!input || typeof input !== 'object') return null;
const record = input;
if (record.ok === true && record.data && typeof record.data === 'object') {
return normalizeDefinition(record.data);
}
if (record.definition && typeof record.definition === 'object') {
return record.definition;
}
if (record.flow && typeof record.flow === 'object') {
const flow = record.flow;
if (flow.definition && typeof flow.definition === 'object') {
return flow.definition;
}
}
if (record.workflow && typeof record.workflow === 'object') {
const workflow = record.workflow;
if (workflow.definition && typeof workflow.definition === 'object') {
return workflow.definition;
}
}
if (record.nodes && record.edges) {
return record;
}
return null;
}
function parseDefinitionInput(raw) {
try {
const parsed = JSON.parse(raw);
return normalizeDefinition(parsed);
} catch {
return null;
}
}
function validateDefinition(definition) {
const errors = [];
const warnings = [];
const nodes = Array.isArray(definition.nodes) ? definition.nodes : null;
const edges = Array.isArray(definition.edges) ? definition.edges : null;
if (!nodes) {
errors.push('definition.nodes must be an array');
}
if (!edges) {
errors.push('definition.edges must be an array');
}
if (!nodes || !edges) {
return { errors, warnings, stats: { nodes: 0, edges: 0, decideNodes: 0 } };
}
const nodeIds = new Set();
const nodeTypes = {};
const outgoingEdges = {};
nodes.forEach((node, index) => {
if (!node || typeof node !== 'object') {
errors.push(`node[${index}] must be an object`);
return;
}
const nodeId = node.id;
if (typeof nodeId !== 'string' || nodeId.trim() === '') {
errors.push(`node[${index}].id must be a non-empty string`);
return;
}
if (nodeIds.has(nodeId)) {
errors.push(`duplicate node id: ${nodeId}`);
return;
}
nodeIds.add(nodeId);
const data = node.data || {};
const nodeType = data.node_type;
if (!nodeType) {
errors.push(`node ${nodeId} is missing data.node_type`);
} else {
nodeTypes[nodeId] = nodeType;
if (!SUPPORTED_NODE_TYPES.has(nodeType)) {
warnings.push(`node ${nodeId} has unknown node_type: ${nodeType}`);
}
}
});
const startNodes = [...nodeIds].filter((id) => id === 'start');
if (startNodes.length !== 1) {
errors.push('graph must contain exactly one start node with id "start"');
} else if (nodeTypes.start && nodeTypes.start !== 'start') {
errors.push('start node must have data.node_type = "start"');
}
edges.forEach((edge, index) => {
if (!edge || typeof edge !== 'object') {
errors.push(`edge[${index}] must be an object`);
return;
}
const source = edge.source;
const target = edge.target;
const label = edge.label;
if (typeof source !== 'string' || !nodeIds.has(source)) {
errors.push(`edge[${index}] has invalid source: ${String(source)}`);
}
if (typeof target !== 'string' || !nodeIds.has(target)) {
errors.push(`edge[${index}] has invalid target: ${String(target)}`);
}
if (typeof label !== 'string' || label.trim() === '') {
errors.push(`edge[${index}] must have a non-empty label`);
}
if (typeof source === 'string') {
outgoingEdges[source] = outgoingEdges[source] || [];
if (typeof label === 'string') {
outgoingEdges[source].push(label);
}
}
});
let decideNodes = 0;
nodes.forEach((node) => {
if (!node || typeof node !== 'object') return;
const nodeId = node.id;
const nodeType = nodeTypes[nodeId];
const outgoing = outgoingEdges[nodeId] || [];
if (nodeType === 'decide') {
decideNodes += 1;
const config = (node.data || {}).config || {};
const conditions = config.conditions;
if (!Array.isArray(conditions) || conditions.length === 0) {
errors.push(`decide node ${nodeId} must have config.conditions[]`);
return;
}
const conditionLabels = conditions
.map((condition) => condition.label)
.filter((label) => typeof label === 'string' && label.trim() !== '');
const uniqueLabels = new Set(conditionLabels);
if (uniqueLabels.size !== conditionLabels.length) {
errors.push(`decide node ${nodeId} has duplicate condition labels`);
}
conditionLabels.forEach((label) => {
if (!outgoing.includes(label)) {
errors.push(`decide node ${nodeId} missing outgoing edge for label "${label}"`);
}
});
outgoing.forEach((label) => {
if (!uniqueLabels.has(label)) {
warnings.push(`decide node ${nodeId} has edge label "${label}" not in conditions`);
}
});
return;
}
if (outgoing.length > 1) {
errors.push(`node ${nodeId} has ${outgoing.length} outgoing edges; only decide nodes can branch`);
}
if (outgoing.length === 1 && outgoing[0] !== 'next') {
errors.push(`node ${nodeId} outgoing edge label must be "next"`);
}
});
return {
errors,
warnings,
stats: {
nodes: nodes.length,
edges: edges.length,
decideNodes
}
};
}
function usage() {
return ok({
usage: 'node scripts/validate-graph.js --workflow-id <id> | --definition-file <path> | --definition-json <json>',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const workflowId = getFlag(parsed.flags, 'workflow-id');
const definitionFile = getFlag(parsed.flags, 'definition-file');
const definitionJson = getFlag(parsed.flags, 'definition-json');
let definition = null;
let source = 'definition-input';
if (workflowId) {
const config = loadConfig();
const response = await requestJson(config, {
method: 'GET',
path: `/platform/v1/workflows/${workflowId}/definition`
});
if (!response.ok) {
printJson(err('Failed to fetch workflow definition', response.raw, false, response.status));
return 2;
}
const workflow = response.data;
definition = workflow && typeof workflow === 'object' ? workflow.definition : null;
if (!definition || typeof definition !== 'object') {
printJson(err('Workflow definition missing in response', response.raw, false, response.status));
return 2;
}
source = `workflow:${workflowId}`;
} else if (definitionFile) {
definition = parseDefinitionInput(readFileText(definitionFile));
if (!definition) {
printJson(err('Unable to parse workflow definition from definition-file'));
return 2;
}
source = definitionFile;
} else if (definitionJson) {
definition = parseDefinitionInput(definitionJson);
if (!definition) {
printJson(err('Unable to parse workflow definition from definition-json'));
return 2;
}
}
if (!definition) {
printJson(err('Provide --workflow-id, --definition-file, or --definition-json'));
return 2;
}
const validation = validateDefinition(definition);
printJson(ok({
source,
valid: validation.errors.length === 0,
errors: validation.errors,
warnings: validation.warnings,
stats: validation.stats
}));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/variables-delete.js
#!/usr/bin/env node
import { loadConfig } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag } from './lib/workflows/args.js';
function usage() {
return ok({
usage: 'node scripts/variables-delete.js <workflow-id> --name <name>',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const workflowId = parsed.args[0] || getFlag(parsed.flags, 'workflow-id');
const name = getFlag(parsed.flags, 'name');
const config = loadConfig();
printJson(err('Workflow variables delete is not available in the Platform API.', {
workflow_id: workflowId,
name,
note: 'Only variable discovery is proposed; CRUD endpoints are missing.'
}, true));
return 2;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/variables-list.js
#!/usr/bin/env node
import { loadConfig, requestJson } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag } from './lib/workflows/args.js';
function usage() {
return ok({
usage: 'node scripts/variables-list.js <workflow-id> [--workflow-id <id>]',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const workflowId = parsed.args[0] || getFlag(parsed.flags, 'workflow-id');
if (!workflowId) {
printJson(err('workflow_id is required'));
return 2;
}
const config = loadConfig();
const response = await requestJson(config, {
method: 'GET',
path: `/platform/v1/workflows/${workflowId}/variables`
});
if (!response.ok && response.status === 404) {
printJson(err('Workflow variables endpoint is not available in the Platform API.', {
endpoint: '/platform/v1/workflows/:id/variables'
}, true, response.status));
return 2;
}
if (!response.ok) {
printJson(err('Failed to fetch workflow variables', response.raw, false, response.status));
return 2;
}
printJson(ok({
workflow_id: workflowId,
variables: response.data
}));
return 0;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
scripts/variables-set.js
#!/usr/bin/env node
import { loadConfig } from './lib/workflows/kapso-api.js';
import { ok, err, printJson } from './lib/workflows/result.js';
import { parseArgs, getFlag, getBooleanFlag } from './lib/workflows/args.js';
function usage() {
return ok({
usage: 'node scripts/variables-set.js <workflow-id> --name <name> --value <value>',
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
});
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (getBooleanFlag(parsed.flags, 'help') || getBooleanFlag(parsed.flags, 'h')) {
printJson(usage());
return 0;
}
const workflowId = parsed.args[0] || getFlag(parsed.flags, 'workflow-id');
const name = getFlag(parsed.flags, 'name');
const value = getFlag(parsed.flags, 'value');
const config = loadConfig();
printJson(err('Workflow variables create/update is not available in the Platform API.', {
workflow_id: workflowId,
name,
value,
note: 'Only variable discovery is proposed; CRUD endpoints are missing.'
}, true));
return 2;
}
main().catch((error) => {
printJson(err('Unhandled error', { message: String(error?.message || error) }));
process.exit(1);
});
SKILL.md
---
name: automate-whatsapp
description: Build and debug Kapso WhatsApp workflows, triggers, functions, and executions.
---
# Automate WhatsApp
## When to use
Use this skill to build and run WhatsApp automations: workflow CRUD, graph edits, WhatsApp and Project Event triggers, Project Event emissions, executions, function management, webhook tools, and MCP tools.
## Setup
Preferred path:
- Kapso CLI installed and authenticated (`kapso login`)
- For workflow and function edits, use source-controlled projects with `kapso link`, `kapso pull`, `kapso build`, and `kapso push`
- For workflow code, use `@kapso/workflows` and export a `Workflow` instance from `workflow.js` or `workflow.ts`
Fallback path:
Env vars:
- `KAPSO_API_BASE_URL` (host only, no `/platform/v1`)
- `KAPSO_API_KEY`
## How to
### Edit workflows locally
Use this path first when the user is working in, or can create, a local repo.
```bash
npm install -g @kapso/cli
npm install --save-dev @kapso/workflows
kapso login
kapso link --project <project-id>
kapso pull
```
Edit `workflows/<workflow-slug>/workflow.js` or `workflow.ts` with `@kapso/workflows`:
```ts
import { START, Workflow } from "@kapso/workflows";
const workflow = new Workflow("inbound-support", {
name: "Inbound Support",
status: "draft",
});
workflow.addTrigger({
type: "inbound_message",
phoneNumberId: "<phone-number-id>",
});
workflow.addNode(START, {
position: { x: 100, y: 100 },
});
workflow.addNode("reply", {
type: "send_text",
message: "Thanks for reaching out.",
});
workflow.addEdge(START, "reply");
export default workflow;
```
Build and push:
```bash
kapso build
kapso push --dry-run
kapso push workflow <workflow-slug>
```
Use `kapso push` to push every local function and workflow. See `references/local-workflow-source.md` for repo layout, source-file behavior, and JSON-only editing.
### Discover phone numbers first
Preferred path:
1. Check project state: `kapso status`
2. List connected numbers: `kapso whatsapp numbers list --output json`
3. Resolve a display number when needed: `kapso whatsapp numbers resolve --phone-number "<display-number>" --output json`
Fallback path:
1. List number configs for triggers: `node scripts/list-whatsapp-phone-numbers.js`
### Edit a workflow graph through API scripts
Prefer local source sync for workflow edits. Use these scripts as a fallback for debugging, direct graph inspection, or API-only environments.
1. Fetch graph: `node scripts/get-graph.js <workflow_id>` (note the `lock_version`)
2. Edit the JSON (see graph rules below)
3. Validate: `node scripts/validate-graph.js --definition-file <path>`
4. Update: `node scripts/update-graph.js <workflow_id> --expected-lock-version <n> --definition-file <path>`
5. Re-fetch to confirm
For small edits, use `edit-graph.js` with `--old-file` and `--new-file` instead.
If you get a lock_version conflict: re-fetch, re-apply changes, retry with new lock_version.
### Manage triggers
1. List: `node scripts/list-triggers.js <workflow_id>`
2. Create: `node scripts/create-trigger.js <workflow_id> --trigger-type <type> --phone-number-id <id>`
3. Toggle: `node scripts/update-trigger.js --trigger-id <id> --active true|false`
4. Delete: `node scripts/delete-trigger.js --trigger-id <id>`
For inbound_message triggers, prefer `kapso whatsapp numbers resolve --phone-number "<display-number>" --output json` to get the exact `phone_number_id`. Fall back to `node scripts/list-whatsapp-phone-numbers.js` when the CLI is unavailable.
For project_event triggers, register or update the Project Event definition first when the user is defining an event type/schema:
```bash
node scripts/project-event-definitions.js create \
--name conversation.csat_scored \
--description "Customer satisfaction score for a conversation" \
--property-schema '{"score":{"type":"number"},"reason":{"type":"string"}}'
```
Then create the trigger:
```bash
node scripts/create-trigger.js <workflow_id> \
--trigger-type project_event \
--triggerable-attributes '{"event_name":"conversation.csat_scored","property_key":"score","operator":"gte","property_value":4}'
```
Definitions are metadata only. Do not emit a sample event just to register a name unless the user explicitly accepts that side effect.
### Manage Project Event definitions
Use definitions for event names, descriptions, and flat scalar property schemas. Emitted events are separate records created by `POST /platform/v1/events`, `emit_event` nodes, Function node `project_events`, or Agent node `emit_event`.
1. List: `node scripts/project-event-definitions.js list`
2. Create/update by name: `node scripts/project-event-definitions.js create --name <event.name> [--description <text>] [--property-schema <json>]`
3. Update by ID: `node scripts/project-event-definitions.js update --definition-id <id> [--name <event.name>] [--description <text>] [--property-schema <json>]`
### Build workflows with Project Events
Use this checklist when a workflow needs to remember or react to durable business facts:
1. Define the event first when the user is introducing a new event name or schema.
2. Use `project_event` triggers when a workflow should react to an emitted event.
3. Use an `emit_event` node for deterministic workflow-step emission.
4. Use Function node `project_events` when emission depends on function code output.
5. Use Agent node `emit_event` only when the agent should decide whether/when to record the fact. Enable the `emit_event` default tool and configure allowed event definitions before relying on it.
Project-event-triggered workflows are observers and cannot emit Project Events. Do not add event emission to a workflow that starts from a Project Event trigger.
### Debug executions
1. Search workflow logs first when you have an execution ID: `kapso logs search --query "<execution-id>" --source flow_event --filter flow_execution_id=<execution-id> --period 7d --limit 20 --output json`
2. List: `node scripts/list-executions.js <workflow_id>`
3. Inspect: `node scripts/get-execution.js <execution-id>`
4. Get value: `node scripts/get-context-value.js <execution-id> --variable-path vars.foo`
5. Events: `node scripts/list-execution-events.js <execution-id>`
### Create and deploy a function
1. Write code with handler signature (see function rules below)
2. Create: `node scripts/create-function.js --name <name> --code-file <path> [--public-endpoint true]`
3. Deploy: `node scripts/deploy-function.js --function-id <id>`
4. Verify: `node scripts/get-function.js --function-id <id>`
Use `--public-endpoint true` when the function should be callable without `X-API-Key` via the Kapso-hosted invoke URL. This is only supported for Cloudflare functions.
New functions default to `invoke_response_mode=passthrough`, which returns the function body directly on successful invoke. Legacy wrapped functions can be migrated later with `update-function.js`.
### Set up agent node with remote sandbox repositories
Use this when the agent needs a remote ephemeral workspace to inspect or modify repository files during a workflow run.
1. Read `references/agent-remote-sandbox.md` for the execution model and field rules
2. Find model: `node scripts/list-provider-models.js`
3. Copy `assets/agent-remote-sandbox-github-repo-example.json` as a starting point, or edit the agent node under `data.config`
4. Set `sandbox_enabled: true`
5. Set `sandbox_network_mode` to `allow_all` or `allow_list`
6. If using `allow_list`, add extra outbound hosts in `sandbox_allowed_outbound_hosts`
7. Add GitHub repositories to `flow_agent_resources` with:
- `resource_type: "github_repository"`
- `repo_url`
- `branch`
- `pat`
8. Write the system prompt so it explicitly reads from `/workspace/repos/<repo-slug>` before making changes
9. Validate and update the graph
Notes:
- Remote sandbox is beta and free during the beta
- `sandbox_enabled` controls whether the remote workspace and sandbox tools are available
- Repository resources stay configured even if sandbox access is turned off later
- v1 supports GitHub repositories only
- Use a repository root URL, not a GitHub file URL or `tree/...` URL
- Repositories are mounted into `/workspace/repos/<repo-slug>` inside the remote sandbox
- Use `references/agent-remote-sandbox.md` and `references/node-types.md` for the exact shape
## Graph rules
- Exactly one start node with `id` = `start`
- Never change existing node IDs
- Use `{node_type}_{timestamp_ms}` for new node IDs
- Non-decide nodes have 0 or 1 outgoing `next` edge
- Decide edge labels must match `conditions[].label`
- Edge keys are `source`/`target`/`label` (not `from`/`to`)
For full schema details, see `references/graph-contract.md`.
## Function rules
```js
async function handler(request, env) {
// Parse input
const body = await request.json();
// Use env.KV and secrets as needed
return new Response(JSON.stringify({ result: "ok" }));
}
```
- Do NOT use `export`, `export default`, or arrow functions
- Return a `Response` object
## Execution context
Always use this structure:
- `vars` - user-defined variables
- `system` - system variables
- `context` - channel data
- `metadata` - request metadata
## Scripts
### Workflows
| Script | Purpose |
|--------|---------|
| `list-workflows.js` | List workflows (metadata only) |
| `get-workflow.js` | Get workflow metadata |
| `create-workflow.js` | Create a workflow |
| `update-workflow-settings.js` | Update workflow settings |
### Graph
| Script | Purpose |
|--------|---------|
| `get-graph.js` | Get workflow graph + lock_version |
| `edit-graph.js` | Patch graph via string replacement |
| `update-graph.js` | Replace entire graph |
| `validate-graph.js` | Validate graph structure locally |
### Triggers
| Script | Purpose |
|--------|---------|
| `list-triggers.js` | List triggers for a workflow |
| `create-trigger.js` | Create a trigger |
| `update-trigger.js` | Enable/disable a trigger |
| `delete-trigger.js` | Delete a trigger |
| `list-whatsapp-phone-numbers.js` | List phone numbers for trigger setup |
### Project Events
| Script | Purpose |
|--------|---------|
| `project-event-definitions.js` | List, create, or update Project Event definitions |
### Executions
| Script | Purpose |
|--------|---------|
| `list-executions.js` | List executions |
| `get-execution.js` | Get execution details |
| `get-context-value.js` | Read value from execution context |
| `update-execution-status.js` | Force execution state |
| `resume-execution.js` | Resume waiting execution |
| `list-execution-events.js` | List execution events |
### Functions
| Script | Purpose |
|--------|---------|
| `list-functions.js` | List project functions |
| `get-function.js` | Get function details + code |
| `create-function.js` | Create a function, optionally with a public invoke endpoint |
| `update-function.js` | Update function code, public endpoint setting, or migrate a legacy wrapped function to passthrough |
| `deploy-function.js` | Deploy function to runtime |
| `invoke-function.js` | Invoke function with payload |
| `list-function-invocations.js` | List function invocations |
### OpenAPI
| Script | Purpose |
|--------|---------|
| `openapi-explore.mjs` | Explore OpenAPI (search/op/schema/where) |
Install deps (once):
```bash
npm i
```
Examples:
```bash
node scripts/openapi-explore.mjs --spec workflows search "variables"
node scripts/openapi-explore.mjs --spec workflows op getWorkflowVariables
```
## Notes
- Prefer file paths over inline JSON (`--definition-file`, `--code-file`)
- Use `observe-whatsapp` for cross-source Logs search when debugging a workflow alongside API calls, Meta events, or webhook deliveries.
- Variable CRUD (`variables-set.js`, `variables-delete.js`) is blocked - Platform API doesn't support it
## References
Read the references for the operation you are changing:
- [references/local-workflow-source.md](references/local-workflow-source.md) - Local workflow source edits and CLI sync
- [references/graph-contract.md](references/graph-contract.md) - Graph edits: schema, computed vs editable fields, and lock_version
- [references/node-types.md](references/node-types.md) - Adding or changing node configuration
- [references/workflow-overview.md](references/workflow-overview.md) - Execution behavior, state changes, and resumption
For function-only work, read the function references below; graph/source references are needed only if the workflow changes too. Other task-specific references:
- [references/execution-context.md](references/execution-context.md) - Context structure and variable substitution
- [references/triggers.md](references/triggers.md) - Trigger types and setup
- [references/agent-remote-sandbox.md](references/agent-remote-sandbox.md) - Remote sandbox behavior, repo resources, mounted paths
- [references/functions-reference.md](references/functions-reference.md) - Function management
- [references/functions-payloads.md](references/functions-payloads.md) - Payload shapes for functions
## Assets
| File | Description |
|------|-------------|
| `workflow-linear.json` | Minimal linear workflow |
| `workflow-decision.json` | Minimal branching workflow |
| `workflow-agent-simple.json` | Minimal agent workflow |
| `workflow-customer-support-intake-agent.json` | Customer support intake |
| `workflow-interactive-buttons-decide-function.json` | Interactive buttons + decide (function) |
| `workflow-interactive-buttons-decide-ai.json` | Interactive buttons + decide (AI) |
| `workflow-api-template-wait-agent.json` | API trigger + template + agent |
| `function-decide-route-interactive-buttons.json` | Function for button routing |
| `agent-remote-sandbox-github-repo-example.json` | Agent node with remote sandbox + GitHub repo resource |
## Related skills
- `integrate-whatsapp` - Onboarding, webhooks, messaging, templates, flows
- `observe-whatsapp` - Debugging, logs, health checks
<!-- FILEMAP:BEGIN -->
```text
[automate-whatsapp file map]|root: .
|.:{package.json,SKILL.md}
|assets:{agent-remote-sandbox-github-repo-example.json,function-decide-route-interactive-buttons.json,functions-example.json,workflow-agent-simple.json,workflow-api-template-wait-agent.json,workflow-customer-support-intake-agent.json,workflow-decision.json,workflow-interactive-buttons-decide-ai.json,workflow-interactive-buttons-decide-function.json,workflow-linear.json}
|references:{agent-remote-sandbox.md,execution-context.md,function-contracts.md,functions-payloads.md,functions-reference.md,graph-contract.md,local-workflow-source.md,node-types.md,triggers.md,workflow-overview.md,workflow-reference.md}
|scripts:{create-function.js,create-trigger.js,create-workflow.js,delete-trigger.js,deploy-function.js,edit-graph.js,get-context-value.js,get-execution-event.js,get-execution.js,get-function.js,get-graph.js,get-workflow.js,invoke-function.js,list-execution-events.js,list-executions.js,list-function-invocations.js,list-functions.js,list-provider-models.js,list-triggers.js,list-whatsapp-phone-numbers.js,list-workflows.js,openapi-explore.mjs,project-event-definitions.js,resume-execution.js,update-execution-status.js,update-function.js,update-graph.js,update-trigger.js,update-workflow-settings.js,validate-graph.js,variables-delete.js,variables-list.js,variables-set.js}
|scripts/lib/functions:{args.js,kapso-api.js}
|scripts/lib/workflows:{args.js,kapso-api.js,result.js}
```
<!-- FILEMAP:END -->