# Complex Workflows Source: https://docs.runflow.ai/advanced/complex-workflows Advanced patterns: supervisor agents, multi-stage pipelines, and production architectures ## Supervisor Pattern A supervisor agent coordinates specialist agents, routing work based on classification: ```typescript theme={null} import { flow, Agent, openai, createAgentStep } from '@runflow-ai/sdk'; import { z } from 'zod'; const supervisor = new Agent({ name: 'Supervisor', instructions: `You are a supervisor that classifies incoming requests. Respond with JSON: { "department": "billing|technical|sales|hr", "urgency": "low|medium|high" }`, model: openai('gpt-4o'), }); const billingAgent = new Agent({ name: 'Billing Specialist', instructions: 'Handle billing, invoices, payments. Be precise with numbers.', model: openai('gpt-4o'), }); const techAgent = new Agent({ name: 'Tech Support', instructions: 'Solve technical problems. Ask clarifying questions if needed.', model: openai('gpt-4o'), }); const salesAgent = new Agent({ name: 'Sales Rep', instructions: 'Handle sales inquiries. Be consultative, not pushy.', model: openai('gpt-4o'), }); const hrAgent = new Agent({ name: 'HR Assistant', instructions: 'Handle HR questions about policies, benefits, time off.', model: openai('gpt-4o'), }); const supervisorWorkflow = flow({ id: 'supervisor-workflow', name: 'Supervisor Multi-Agent System', inputSchema: z.object({ message: z.string(), userId: z.string(), }), outputSchema: z.any(), }) // Step 1: Supervisor classifies .agent('classify', supervisor, { promptTemplate: 'Classify this request: {{input.message}}', }) // Step 2: Parse classification .step('parse', { outputSchema: z.object({ department: z.enum(['billing', 'technical', 'sales', 'hr']), urgency: z.enum(['low', 'medium', 'high']), }), handler: async (input) => { try { return JSON.parse(input.text); } catch { return { department: 'technical', urgency: 'medium' }; } }, }) // Step 3: Route to specialist .switch('route', { on: (ctx) => ctx.results.parse.department, cases: { billing: [createAgentStep('billing', billingAgent, { promptTemplate: 'Customer request: {{input.message}}', })], technical: [createAgentStep('tech', techAgent, { promptTemplate: 'Technical issue: {{input.message}}', })], sales: [createAgentStep('sales', salesAgent, { promptTemplate: 'Sales inquiry: {{input.message}}', })], hr: [createAgentStep('hr', hrAgent, { promptTemplate: 'HR question: {{input.message}}', })], }, }) // Step 4: High-urgency notification .step('notify', { handler: async (input, ctx) => { console.log(`Alert: ${ctx.results.parse.urgency} urgency ticket routed to ${ctx.results.parse.department}`); return { ...input, notified: true }; }, when: (ctx) => ctx.results.parse.urgency === 'high', }) .output((results, input) => ({ userId: input.userId, department: results.parse.department, urgency: results.parse.urgency, response: results.route.text, })) .build(); ``` ## Data Processing Pipeline Process, enrich, and aggregate data with parallel steps and iteration: ```typescript theme={null} const dataPipeline = flow({ id: 'data-pipeline', name: 'Customer Data Pipeline', inputSchema: z.object({ customerIds: z.array(z.string()), }), outputSchema: z.any(), }) // Extract customer IDs .map((input) => input.customerIds) // Fetch all customer data concurrently .foreach('fetch', { handler: async (customerId) => { const profile = await db.getCustomer(customerId); const orders = await db.getOrders(customerId); return { id: customerId, name: profile.name, totalOrders: orders.length, totalSpent: orders.reduce((sum, o) => sum + o.amount, 0), lastOrder: orders[0]?.date || null, }; }, concurrency: 20, }) // Segment customers .step('segment', async (customers: any[]) => { const segments = { vip: customers.filter(c => c.totalSpent > 50000), active: customers.filter(c => c.totalSpent > 5000 && c.totalSpent <= 50000), dormant: customers.filter(c => c.totalSpent <= 5000), }; return { segments, total: customers.length }; }) // Generate insights with AI for VIP segment .step('vip-insights', { handler: async (input, ctx) => { const vipSummary = input.segments.vip .map((c: any) => `${c.name}: $${c.totalSpent}, ${c.totalOrders} orders`) .join('\n'); return { ...input, vipCount: input.segments.vip.length, summary: vipSummary, }; }, when: (ctx) => ctx.results.segment.segments.vip.length > 0, }) .output((results) => ({ segments: results.segment.segments, total: results.segment.total, vipInsights: results['vip-insights']?.summary || 'No VIP customers', })) .build(); ``` ## Multi-Stage Approval Pipeline A pipeline where each stage validates the previous one's output: ```typescript theme={null} const approvalPipeline = flow({ id: 'content-approval', name: 'Content Approval Pipeline', inputSchema: z.object({ topic: z.string(), audience: z.string(), tone: z.enum(['formal', 'casual', 'technical']), }), outputSchema: z.any(), }) // Stage 1: Draft .agent('draft', new Agent({ name: 'Content Writer', instructions: 'Write engaging content. Follow brand guidelines.', model: openai('gpt-4o'), }), { promptTemplate: 'Write a {{input.tone}} article about {{input.topic}} for {{input.audience}}.', }) // Stage 2: Review .agent('review', new Agent({ name: 'Content Reviewer', instructions: `Review content for quality. Respond with JSON: { "approved": boolean, "score": 1-10, "issues": string[] }`, model: openai('gpt-4o'), }), { promptTemplate: 'Review this content:\n\n{{results.draft.text}}', }) // Stage 3: Parse review .step('parse-review', async (input) => { try { return JSON.parse(input.text); } catch { return { approved: false, score: 0, issues: ['Failed to parse review'] }; } }) // Stage 4: Route based on approval .branch('approval', { condition: (ctx) => ctx.results['parse-review'].approved === true, onTrue: async (input, ctx) => ({ status: 'approved', content: ctx.results.draft.text, score: input.score, }), onFalse: async (input, ctx) => ({ status: 'needs-revision', content: ctx.results.draft.text, issues: input.issues, score: input.score, }), }) .output((results, input) => ({ topic: input.topic, ...results.approval, })) .build(); ``` ## Enrichment + Scoring Pipeline Combine data from multiple sources and score: ```typescript theme={null} const leadScoring = flow({ id: 'lead-scoring', name: 'Lead Scoring Pipeline', inputSchema: z.object({ email: z.string().email(), company: z.string(), jobTitle: z.string(), }), outputSchema: z.any(), }) // Parallel enrichment from multiple sources .parallel('enrich', [ createFunctionStep('clearbit', async (input) => { // Simulate enrichment API return { employees: 500, industry: 'SaaS', funding: '$50M' }; }), createFunctionStep('linkedin', async (input) => { return { connections: 1200, seniority: 'Director' }; }), createFunctionStep('crm-history', async (input) => { return { previousDeals: 2, lastContact: '2026-01-15', status: 'active' }; }), ]) // Calculate score .step('score', async (input) => { const data = input.results; let score = 0; // Company size if (data.clearbit.employees > 200) score += 30; else if (data.clearbit.employees > 50) score += 15; // Seniority if (['VP', 'Director', 'C-Level'].includes(data.linkedin.seniority)) score += 25; // Previous relationship if (data['crm-history'].previousDeals > 0) score += 30; if (data['crm-history'].status === 'active') score += 15; return { score, tier: score >= 70 ? 'hot' : score >= 40 ? 'warm' : 'cold', enrichment: data, }; }) // Route by tier .switch('action', { on: (ctx) => ctx.results.score.tier, cases: { hot: async (input) => ({ action: 'schedule-demo', assignTo: 'senior-ae' }), warm: async (input) => ({ action: 'nurture-sequence', assignTo: 'sdr' }), cold: async (input) => ({ action: 'add-to-drip', assignTo: 'marketing' }), }, }) .output((results, input) => ({ email: input.email, company: input.company, score: results.score.score, tier: results.score.tier, action: results.action, })) .build(); ``` ## Migration from Legacy API If you're using the legacy `createWorkflow()` API, here's how to migrate: ```typescript theme={null} // BEFORE (legacy) import { createWorkflow, createAgentStep } from '@runflow-ai/sdk'; const wf = createWorkflow({ id: 'my-wf', inputSchema, outputSchema }) .function('classify', async (input) => ({ category: 'sales' })) .condition( 'route', (ctx) => ctx.stepResults.get('classify')?.category === 'sales', [createAgentStep('sales', salesAgent)], [createAgentStep('support', supportAgent)], ) .output((stepResults) => ({ response: stepResults.route?.result?.text, })) .build(); ``` ```typescript theme={null} // AFTER (V2) import { flow, createAgentStep } from '@runflow-ai/sdk'; const wf = flow({ id: 'my-wf', inputSchema, outputSchema }) .step('classify', async (input) => ({ category: 'sales' })) .switch('route', { on: (ctx) => ctx.results.classify.category, cases: { sales: [createAgentStep('sales', salesAgent)], support: [createAgentStep('support', supportAgent)], }, }) .output((results) => ({ response: results.route.text, })) .build(); ``` Key differences: * `createWorkflow()` becomes `flow()` * `.function()` becomes `.step()` * `.condition()` becomes `.branch()` or `.switch()` * `ctx.stepResults.get('name')` becomes `ctx.results.name` * `stepResults` in `.output()` becomes `results` ## Next Steps Core workflow concepts Integrate external services # Custom Memory Provider Source: https://docs.runflow.ai/advanced/custom-memory-provider Implement custom memory storage ## Custom Memory Provider ```typescript theme={null} import { Memory, MemoryProvider } from '@runflow-ai/sdk'; class RedisMemoryProvider implements MemoryProvider { async get(key: string): Promise { const data = await redis.get(key); return JSON.parse(data); } async set(key: string, data: MemoryData): Promise { await redis.set(key, JSON.stringify(data)); } async append(key: string, message: MemoryMessage): Promise { const data = await this.get(key); data.messages.push(message); await this.set(key, data); } async clear(key: string): Promise { await redis.del(key); } } // Use custom provider const memory = new Memory({ provider: new RedisMemoryProvider(), maxTurns: 10, }); ``` ## Next Steps Learn about memory Learn about providers # Multi-Modal (Images & Files) Source: https://docs.runflow.ai/advanced/multi-modal Send images and files to vision-capable models Runflow accepts multimodal content — text + image, text + file, or text + multiple attachments — through three entry points. Pick the one that matches how the media reaches your agent. ## 1. Direct multimodal call Build the `messages` array yourself and pass it to `agent.process`. Best when you already have URLs or base64 strings on hand. ```typescript theme={null} import { Agent, openai } from '@runflow-ai/sdk'; const agent = new Agent({ name: 'vision-agent', instructions: 'You can analyze images.', model: openai('gpt-4o'), }); await agent.process({ messages: [ { role: 'user', content: [ { type: 'text', text: 'What is in this image?' }, { type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }, ], }, ], }); ``` The same `content` array works across providers — the SDK translates the parts to the native format each model expects (OpenAI, Anthropic, Bedrock, Gemini, Groq, xAI, Azure OpenAI). ## 2. multipart/form-data uploads When a client sends a file or image directly to the agent endpoint over HTTP, post `multipart/form-data`. Runflow stores the upload and exposes it to your agent as `input.attachments[]`. ```bash theme={null} curl -X POST "https://executor.runflow.ai/agent/?token=" \ -F "message=quanto custa esse produto?" \ -F "photo=@./produto.jpg" ``` Enable `media.processAttachments` to let the SDK build the multimodal message for you: ```typescript theme={null} const agent = new Agent({ name: 'product-helper', model: openai('gpt-4o'), instructions: 'Help the user evaluate products.', media: { processAttachments: true }, }); ``` Routing rule: * `content_type` starting with `image/` → `image_url` * anything else → `file_url` For custom routing (OCR a PDF, parse a CSV locally, etc.), leave the flag off and transform `input.attachments[]` yourself — see [Media Processing](/core-concepts/media-processing) for the manual recipe. ## 3. Webhook handlers with `input.file` Twilio/WhatsApp and Meta/Messenger handlers deliver a single media file per message as `input.file`. Auto-processed when `media.transcribeAudio` or `media.processImages` is enabled. ```typescript theme={null} const agent = new Agent({ name: 'whatsapp-bot', model: openai('gpt-4o'), instructions: 'Reply in the customer\'s language.', media: { transcribeAudio: true, processImages: true }, }); ``` See [Media Processing](/core-concepts/media-processing) for the full WhatsApp example. ## Provider support at a glance | Provider | Images | Files (PDF, CSV, …) | | ------------------- | ----------------------------- | ------------------------------------ | | OpenAI | URL or base64 | `file_id` (uploaded via Runtime API) | | Azure OpenAI | URL or base64 | text label only | | Anthropic / Bedrock | URL or base64 | text label only | | Gemini | base64 inline / `data:` URI | text label only | | Groq / xAI | URL or base64 (vision models) | text label only | For non-image files on providers that don't support arbitrary documents, the SDK emits a `[File: ]` text placeholder so the model sees something coherent. If you need the model to actually read a PDF/CSV, parse it locally first and send the extracted text as a `text` part. ## Next Steps Full media handling guide with WhatsApp example Stream multimodal responses # Reasoning & Extended Thinking Source: https://docs.runflow.ai/advanced/reasoning Enable chain-of-thought reasoning for complex tasks Some models can "think" before responding -- breaking down complex problems step by step. This improves accuracy for math, logic, coding, and analysis tasks. ## Anthropic Extended Thinking ```typescript theme={null} const agent = new Agent({ name: 'Math Tutor', instructions: 'Solve problems step by step.', model: anthropic('claude-sonnet-4-6'), modelConfig: { thinking: { type: 'enabled', budgetTokens: 10000 } } }); const result = await agent.process({ message: 'What is 17! / 15!?' }); console.log(result.message); // "272" // Thinking content available in traces (Portal > Executions) ``` ## OpenAI Reasoning Models (o-series) OpenAI's o-series models reason natively -- no configuration needed: ```typescript theme={null} const agent = new Agent({ name: 'Analyst', instructions: 'Analyze data carefully.', model: openai('o4-mini'), }); ``` Models: `o1`, `o3`, `o3-mini`, `o4-mini` Reasoning models don't support `temperature`, `top_p`, `frequency_penalty`, `presence_penalty`, or `stop`. These parameters are automatically stripped. ## Gemini Thinking Gemini 2.5+ models support thinking with a token budget: ```typescript theme={null} const agent = new Agent({ name: 'Coder', instructions: 'Write clean code.', model: gemini('gemini-2.5-flash'), modelConfig: { thinking: { type: 'enabled', budgetTokens: 2048 } } }); ``` ## xAI Reasoning Models xAI Grok models with `-reasoning` in the name use chain-of-thought: ```typescript theme={null} const agent = new Agent({ name: 'Researcher', instructions: 'Research thoroughly.', model: xai('grok-4-1-fast-reasoning'), }); ``` ## Thinking in Streaming When using `processStream()`, thinking content arrives as separate chunks: ```typescript theme={null} const stream = await agent.processStream({ message: 'Solve this equation...' }); for await (const chunk of stream) { if (chunk.type === 'thinking') { console.log('[Thinking]', chunk.data.content); } else if (chunk.type === 'content') { process.stdout.write(chunk.data.content); } } ``` ## Testing in Prompt Studio Test thinking directly in the Portal without deploying an agent: 1. Go to **Prompts** and open any prompt 2. Click the **config icon** (sliders) in the top bar 3. Click **Thinking** to enable it (set budget tokens if needed) 4. Send a message -- the thinking content appears as a collapsible "Pensou sobre a resposta" block Works with Anthropic and Gemini providers. OpenAI and xAI reasoning models think natively without the toggle. ## With LLM Standalone ```typescript theme={null} const thinker = LLM.anthropic('claude-sonnet-4-6', { thinking: { type: 'enabled', budgetTokens: 8000 } }); const result = await thinker.generate('Solve: if 2x + 5 = 17, what is x?'); console.log(result.text); // "x = 6" console.log(result.thinking); // "Let me solve step by step: 2x + 5 = 17, 2x = 12, x = 6" ``` ## Provider Support | Provider | How | Configuration | | --------- | ------------------------ | ------------------------------------------------ | | Anthropic | `thinking` parameter | `thinking: { type: 'enabled', budgetTokens: N }` | | OpenAI | Native (o-series models) | Just use o1/o3/o4-mini models | | Gemini | `thinkingConfig` | `thinking: { type: 'enabled', budgetTokens: N }` | | xAI | Reasoning model names | Use `grok-*-reasoning` models | | Groq | Not supported | - | | Bedrock | Not supported | - | ## Next Steps Get guaranteed JSON responses Real-time streaming responses # Server-Side Tools Source: https://docs.runflow.ai/advanced/server-tools Use provider-native tools like web search and code execution Some LLM providers offer **server-side tools** that execute on their infrastructure -- no client-side code needed. The model decides when to use them. ## Anthropic Server Tools ### Web Search ```typescript theme={null} const agent = new Agent({ name: 'Research Agent', instructions: 'Search the web to answer questions with citations.', model: anthropic('claude-sonnet-4-6'), modelConfig: { serverTools: [ { type: 'web_search_20250305', name: 'web_search' } ] } }); const result = await agent.process({ message: 'What are the latest AI breakthroughs this week?' }); ``` ### Code Execution ```typescript theme={null} const agent = new Agent({ name: 'Data Analyst', instructions: 'Analyze data using Python code.', model: anthropic('claude-sonnet-4-6'), modelConfig: { serverTools: [ { type: 'code_execution_20250825', name: 'code_execution' } ] } }); ``` ### Both Together ```typescript theme={null} modelConfig: { serverTools: [ { type: 'web_search_20250305', name: 'web_search' }, { type: 'code_execution_20250825', name: 'code_execution' } ] } ``` ## xAI Server Tools xAI Grok models support native web search, X/Twitter search, and code execution. These work via the Responses API. xAI native tools (web\_search, x\_search, code\_interpreter) require the Responses API format, which is not yet proxied through Runflow. Coming soon. For now, use Anthropic server tools or custom function tools for web search. ## Provider Support | Provider | Web Search | Code Execution | X Search | | --------- | --------------------- | ------------------------- | ----------- | | Anthropic | `web_search_20250305` | `code_execution_20250825` | - | | xAI | Coming soon | Coming soon | Coming soon | | OpenAI | - | - | - | | Gemini | - | - | - | | Groq | - | - | - | ## Testing in Prompt Studio Test server tools in the Portal: 1. Open **Prompts** with an **Anthropic** provider selected 2. Click the config icon -- you'll see **Search** and **Code** buttons 3. Click **Search** to enable web search 4. Ask a question that requires current information -- the model will search the web automatically Server tools are only available for Anthropic providers in the Prompt Studio. Web search adds latency (5-15 seconds) as the model performs real web queries. ## Next Steps Get guaranteed JSON responses Enable chain-of-thought thinking # Streaming Source: https://docs.runflow.ai/advanced/streaming Real-time streaming with thinking, tool calls, and memory Stream responses in real-time using `processStream()`. Supports content chunks, thinking/reasoning, tool calls, and memory persistence. ## Basic Streaming ```typescript theme={null} const stream = await agent.processStream({ message: 'Tell me a story', sessionId: 'session_123', }); for await (const chunk of stream) { if (chunk.type === 'content') { process.stdout.write(chunk.data.content); } } ``` ## Chunk Types Your stream can receive different chunk types: | Type | Description | Data | | ------------------ | ------------------------------------------ | ------------------------------------ | | `content` | Text response from the model | `{ content: string, done: boolean }` | | `thinking` | Reasoning/thinking content | `{ content: string, done: boolean }` | | `internal_process` | Tool call start/complete, memory load/save | `{ processType, status, process }` | | `done` | Stream complete | `{ message, metadata }` | | `error` | Error occurred | `{ error: string }` | ## Streaming with Thinking When `thinking` is enabled, reasoning content arrives as separate chunks before the final response: ```typescript theme={null} const agent = new Agent({ name: 'Analyst', model: anthropic('claude-sonnet-4-6'), modelConfig: { thinking: { type: 'enabled', budgetTokens: 5000 } } }); const stream = await agent.processStream({ message: 'Why is the sky blue?' }); for await (const chunk of stream) { switch (chunk.type) { case 'thinking': console.log('[Thinking]', chunk.data.content); break; case 'content': process.stdout.write(chunk.data.content); break; case 'internal_process': if (chunk.data.status === 'started') { console.log(`[${chunk.data.processType}] started...`); } break; } } ``` ## Streaming with Tool Calls Tool calls are reported as `internal_process` chunks. The agent handles the tool loop automatically: ```typescript theme={null} const agent = new Agent({ name: 'Assistant', model: openai('gpt-4o'), tools: { get_weather: { name: 'get_weather', description: 'Get weather for a location', parameters: { location: { type: 'string', description: 'City name', required: true } }, execute: async ({ location }) => { return { temp: 22, condition: 'sunny' }; } } } }); const stream = await agent.processStream({ message: 'Weather in Tokyo?' }); for await (const chunk of stream) { if (chunk.type === 'content') { process.stdout.write(chunk.data.content); } else if (chunk.type === 'internal_process') { const proc = chunk.data; if (proc.processType === 'tool_call' && proc.status === 'started') { console.log(`\nCalling tool: ${proc.process.name}`); } if (proc.processType === 'tool_call' && proc.status === 'completed') { console.log(`Tool result: ${JSON.stringify(proc.result)}`); } } } ``` ## Streaming with Memory Memory is automatically loaded before and saved after streaming: ```typescript theme={null} const agent = new Agent({ name: 'Chat', model: openai('gpt-4o'), memory: { maxTurns: 20 } }); // Memory chunks appear as internal_process const stream = await agent.processStream({ message: 'Continue our conversation', sessionId: 'session_abc', }); for await (const chunk of stream) { if (chunk.type === 'internal_process' && chunk.data.processType === 'memory_load') { console.log('Memory loaded:', chunk.data.result?.messagesCount, 'messages'); } if (chunk.type === 'content') { process.stdout.write(chunk.data.content); } } ``` ## LLM Standalone Streaming Direct LLM streaming without agents: ```typescript theme={null} const llm = LLM.anthropic('claude-sonnet-4-6', { thinking: { type: 'enabled', budgetTokens: 3000 } }); for await (const chunk of llm.generateStream('Explain quantum computing')) { if (chunk.thinking) { console.log('[Think]', chunk.thinking); } if (chunk.text) { process.stdout.write(chunk.text); } } ``` ## Testing in Prompt Studio You can test streaming behavior directly in the Portal's Prompt Studio: 1. Open **Prompts** and select or create a prompt 2. Click the config icon and enable **Thinking** 3. Send a message -- you'll see the thinking content appear as a collapsible block above the response ## Next Steps Extended thinking for complex tasks Conversation persistence # Structured Output (JSON Mode) Source: https://docs.runflow.ai/advanced/structured-output Get guaranteed JSON responses from any LLM provider Force LLM responses into valid JSON format. Supports `json_object` (free-form JSON) and `json_schema` (schema-validated JSON). ## Basic JSON Mode ```typescript theme={null} const agent = new Agent({ name: 'Data Extractor', instructions: 'Extract structured data from text.', model: openai('gpt-4o'), modelConfig: { responseFormat: { type: 'json_object' } } }); ``` ## Schema-Validated JSON Force the response to match a specific schema: ```typescript theme={null} const agent = new Agent({ name: 'Profile Extractor', instructions: 'Extract person profile from text.', model: openai('gpt-4o'), modelConfig: { responseFormat: { type: 'json_schema', json_schema: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' }, email: { type: 'string' }, }, required: ['name', 'age', 'email'], additionalProperties: false, } } } }); const result = await agent.process({ message: 'John Smith, 34, john@example.com' }); const profile = JSON.parse(result.message); // { name: 'John Smith', age: 34, email: 'john@example.com' } ``` ## Provider Support | Provider | `json_object` | `json_schema` | How | | --------- | ------------- | -------------------- | ------------------------------------- | | OpenAI | Native | Native | `response_format` API parameter | | Gemini | Native | Native | `responseMimeType` + `responseSchema` | | Anthropic | Not supported | Native (Claude 4.5+) | `output_config.format` | | Bedrock | Not supported | Native (Claude 4.5+) | `output_config` in payload | | Groq | Native | Not supported | `response_format` (OpenAI-compatible) | | xAI | Native | Native | `response_format` (OpenAI-compatible) | When `json_object` is not natively supported (Anthropic, Bedrock), add JSON instructions to your system prompt for best results. ## With LLM Standalone ```typescript theme={null} const extractor = LLM.openai('gpt-4o', { responseFormat: { type: 'json_object' } }); const result = await extractor.generate('List 3 colors with hex codes', { system: 'Respond with valid JSON only.' }); const data = JSON.parse(result.text); ``` ## Anthropic Native JSON Schema Anthropic Claude 4.5+ models support schema-validated JSON via `output_config`: ```typescript theme={null} const agent = new Agent({ name: 'Extractor', model: anthropic('claude-sonnet-4-6'), modelConfig: { responseFormat: { type: 'json_schema', json_schema: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' }, }, required: ['name', 'age'], additionalProperties: false, } } } }); ``` Anthropic requires `additionalProperties: false` on all object types in the schema. Models before Claude 4.5 do not support `json_schema`. ## Testing in Prompt Studio Test structured output in the Portal: 1. Open **Prompts** and select a prompt 2. Click the config icon and change **Format** to "JSON Object" or "JSON Schema" 3. Send a message -- the response will be valid JSON ## Next Steps Enable chain-of-thought thinking Provider-native web search and code execution # API Client & REST Endpoints Source: https://docs.runflow.ai/api-reference/api-client SDK API client and the public runtime REST surface (execution reviews, traces, dashboards) The Runflow runtime API is callable two ways: 1. **From SDK code** — use `createRunflowAPIClient()` (and the higher-level `Reviews` / `Memory` / `Knowledge` modules built on top of it). 2. **From any HTTP client** — talk to the runtime endpoints directly, e.g. from a service that doesn't run the SDK. This page covers both — the SDK exports first, then the raw REST surface. ## SDK API Client ```typescript theme={null} import { createRunflowAPIClient } from '@runflow-ai/sdk'; const api = createRunflowAPIClient(); // reads RUNFLOW_API_URL, RUNFLOW_API_KEY, RUNFLOW_AGENT_ID ``` The factory follows a config priority: explicit options → `rf.json` → env vars → defaults. The resulting client exposes typed methods for `chat`, `chatStream`, `vectorSearch`, `connector`, `memory.*`, `documents.*`, `prompts.*`, and `reviews.*` (since SDK ≥ `1.1.13`). Higher-level modules — `Reviews`, `Memory`, `Knowledge`, `RAG`, `LLM` — wrap this client with stricter argument validation and typed errors. Prefer them in agent code; reach for the raw client only when you need something the modules don't cover. ## Authentication All runtime endpoints accept an API key in the `x-api-key` header (or as a `Bearer` token in `Authorization`): ```http theme={null} GET /api/v1/runtime/v1/observability/reviews x-api-key: rf_live_... ``` Get an API key from the portal under **Settings → API Keys**. Each key is tenant-scoped; the platform derives `tenantId` from the key and applies it to every query. The `reviewedBy` / `updatedBy` audit fields on execution reviews are auto-populated from the **API key label** — name your keys descriptively (e.g. `qa-bot`, `nightly-export`) so the audit trail is readable. ## Base URL ``` https://api.runflow.ai ``` The runtime surface lives under the `/api/v1/runtime/v1/observability/...` prefix. Paths below are shown relative to the base URL. ## Execution Reviews CRUD + stats + training-data export for the execution-review feedback loop. The SDK's [`Reviews`](/api-reference/observability#execution-reviews) class wraps these endpoints — use it from agent code unless you need raw HTTP. ### Create a review ```http theme={null} POST /api/v1/runtime/v1/observability/executions/:executionId/reviews ``` ```json theme={null} { "agentId": "agent-uuid", "rating": "bad", "comment": "Bot gave wrong business hours (≥ 10 chars required)", "priority": "high", "tags": ["hours_wrong"] } ``` Each execution can only have **one** review. A second `POST` returns `409 Conflict`. Use the `exists` endpoint below for an idempotent check. ### Check if an execution already has a review ```http theme={null} GET /api/v1/runtime/v1/observability/executions/:executionId/reviews/exists ``` Response: `{ "exists": true, "review": { "id", "rating", "status", "comment", "createdAt" } }`. ### List reviews ```http theme={null} GET /api/v1/runtime/v1/observability/reviews ``` **Query params (all optional):** | Param | Type | Description | | --------------------- | -------- | -------------------------------------------------------- | | `agentId` | string | Filter by agent. | | `status` | enum | `pending_review`, `in_progress`, `resolved`, `wont_fix`. | | `rating` | enum | `good`, `bad`, `needs_improvement`. | | `priority` | enum | `low`, `medium`, `high`, `critical`. | | `dateFrom` / `dateTo` | ISO 8601 | Date range. | | `search` | string | Portuguese full-text search across comments. | | `limit` | number | Default 50, capped at 100. | | `offset` | number | Pagination offset. | ### Get a single review ```http theme={null} GET /api/v1/runtime/v1/observability/reviews/:reviewId ``` ### Update a review ```http theme={null} PATCH /api/v1/runtime/v1/observability/reviews/:reviewId ``` Partial update — send only the fields you want to change. Allowed fields: `status`, `actionTaken`, `resolutionNotes`, `correctedOutput`, `comment`, `priority`, `tags`. Setting `status: "resolved"` auto-stamps `resolvedBy` and `resolvedAt`. ### Delete a review ```http theme={null} DELETE /api/v1/runtime/v1/observability/reviews/:reviewId ``` Hard delete. ### Stats ```http theme={null} GET /api/v1/runtime/v1/observability/reviews/stats?agentId=... ``` `agentId` is required. Response: `{ total, pending, inProgress, resolved, badReviews, needsImprovement, critical, highPriority, avgResolutionHours }`. ### Export for training ```http theme={null} GET /api/v1/runtime/v1/observability/reviews/export/training-data?agentId=...&status=resolved ``` Returns an array of OpenAI conversational fine-tuning examples — one per matching review, with the `correctedOutput` substituted into the assistant turn when present. ```json theme={null} { "training_examples": [ { "messages": [ { "role": "user", "content": "..." }, { "role": "assistant", "content": "..." } ], "metadata": { "review_id": "...", "execution_id": "...", "rating": "bad", "was_corrected": true } } ], "total": 1, "format": "openai-conversational", "generated_at": "2026-05-20T12:00:00.000Z" } ``` Typical filter: `{ agentId, status: 'resolved' }` to pull only human-reviewed and corrected examples. ## Dashboards & Events The pieces of the metrics pipeline that are exposed to **API-key callers** (SDK, CLI, server-to-server): ### Ingest events ```http theme={null} POST /api/v1/runtime/v1/observability/events ``` Used by the SDK's `track()` and CLI tools to push raw events. The events are written async (fire-and-forget) and become queryable within a few seconds. ```json theme={null} { "events": [ { "eventName": "alert_received", "properties": { "severity": "high", "company": "NW" }, "timestamp": "2026-05-20T12:00:00.000Z" }, { "eventName": "ticket_resolved", "properties": { "duration": 45, "category": "network" } } ] } ``` `tenantId` and `agentId` are derived from the API key — you don't need to pass them. ### Recent events feed ```http theme={null} GET /api/v1/runtime/v1/observability/events/feed ``` Most-recent-first list of events for an agent. Useful for debugging emissions, building external "live event" widgets, or sanity-checking that `track()` calls landed. **Query params:** | Param | Type | Description | | ----------- | ------ | ----------------------------------------------------- | | `agentId` | string | Optional. Defaults to the agent bound to the API key. | | `eventName` | string | Optional filter. | | `limit` | number | Default 50, capped at 100. | | `offset` | number | Pagination offset. | Response: ```json theme={null} { "events": [ { "id": "ev-uuid", "eventName": "alert_received", "properties": { "severity": "high", "company": "NW" }, "threadId": "thread-uuid", "executionId": "exec-uuid", "timestamp": "2026-05-20T12:00:00.000Z" } ], "total": 142 } ``` ### Query events (raw or grouped) ```http theme={null} POST /api/v1/runtime/v1/observability/events/query ``` Table-style query against the event store. Two modes: * **`mode: 'raw'`** (default) — returns individual events with the columns you ask for. Use for backfill jobs, exports, or anything that needs the underlying rows. * **`mode: 'aggregate'`** — groups rows by a single property key and applies one or more metrics (`count`, `sum`, `avg`, `min`, `max`). Use for table-shaped reports. The KPI-card style standalone aggregate (one number per query — sum/avg/count/rate of a metric) remains portal-only for now; this endpoint is for the table-shaped reads. **Body:** | Field | Type | Description | | ---------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `agentId` | string | Optional. Defaults to the API-key agent. | | `eventName` | string | **Required**. | | `mode` | `'raw' \| 'aggregate'` | Default `'raw'`. | | `columns` | `string[]` | (raw mode) Property keys to project. Omit for all. | | `groupBy` | string | (aggregate mode) Property key to group on. | | `metrics` | `Array<{ aggregation, propertyKey?, alias }>` | (aggregate mode) One per output column. `aggregation` is one of `count`, `sum`, `avg`, `min`, `max`. | | `filters` | `Array<{ propertyKey, operator, value }>` | Operators: `eq`, `neq`, `gt`, `lt`, `gte`, `lte`, `contains`, `in`. | | `dateFrom` / `dateTo` | ISO 8601 | Date range. | | `sortBy` / `sortOrder` | string / `asc \| desc` | Result ordering. | | `limit` / `offset` | number | Pagination. | Response: ```json theme={null} { "rows": [ { "severity": "high", "count": 42, "total_duration": 1860 } ], "total": 1, "columns": [ { "key": "severity", "type": "string" }, { "key": "count", "type": "number" }, { "key": "total_duration", "type": "number" } ] } ``` Example — raw mode for a backfill job: ```json theme={null} { "eventName": "sale", "mode": "raw", "columns": ["amount", "category", "customer_id"], "filters": [{ "propertyKey": "category", "operator": "eq", "value": "electronics" }], "dateFrom": "2026-05-01T00:00:00Z", "limit": 500 } ``` Example — aggregate mode for a "sales by category" table: ```json theme={null} { "eventName": "sale", "mode": "aggregate", "groupBy": "category", "metrics": [ { "aggregation": "count", "alias": "orders" }, { "aggregation": "sum", "propertyKey": "amount", "alias": "revenue" } ], "dateFrom": "2026-05-01T00:00:00Z" } ``` ### List dashboard cards ```http theme={null} GET /api/v1/runtime/observability/dashboard-cards?agentId=... ``` Returns every card configured for the agent, with its `cardType`, `config`, and `gridLayout`. Useful for read-only views of what's published, or for snapshotting a dashboard into version control. ```json theme={null} { "success": true, "total": 5, "cards": [ { "id": "card-uuid", "agentId": "agent-uuid", "title": "Funil de checkout", "cardType": "funnel", "config": { /* card-type-specific shape */ }, "sortOrder": 0, "tabId": "tab-uuid" } ] } ``` ### Upsert a dashboard card ```http theme={null} POST /api/v1/runtime/observability/dashboard-cards ``` Idempotent on `(agentId, eventName, cardType, aggregation, propertyKey)`. This is the endpoint `metrics.sync()` (SDK) and `rf metrics sync` (CLI) call under the hood — you can call it directly from server-to-server jobs. ```json theme={null} { "agentId": "agent-uuid", "title": "Total alerts", "cardType": "number", "config": { "eventName": "alert_received", "aggregation": "count" }, "tabId": "tab-uuid", "gridLayout": { "x": 0, "y": 0, "w": 4, "h": 3 }, "idempotencyKey": "number:Total alerts:Overview" } ``` `cardType` must be one of `number`, `rate`, `line`, `bar`, `pie`, `table`, `funnel`, `gauge`. The per-type `config` shape is validated server-side; see the portal Metrics tab for the canonical UI. ### List dashboard tabs ```http theme={null} GET /api/v1/runtime/observability/dashboard-tabs?agentId=... ``` Returns the tabs configured for the agent, ordered by `sortOrder` ASC. ```json theme={null} { "success": true, "total": 2, "tabs": [ { "id": "tab-uuid", "name": "Overview", "sortOrder": 0 }, { "id": "tab-uuid", "name": "Vendas", "sortOrder": 1 } ] } ``` ### Upsert a dashboard tab ```http theme={null} POST /api/v1/runtime/observability/dashboard-tabs ``` Idempotent on `(agentId, name)`. This is the endpoint `metrics.defineTab(...) → metrics.sync()` (SDK) and `rf metrics sync` (CLI) call. ```json theme={null} { "agentId": "agent-uuid", "name": "Vendas", "sortOrder": 0 } ``` Response: `{ "success": true, "created": true, "id": "tab-uuid", "tab": { ... } }`. `created` is `false` on subsequent calls with the same name; if `sortOrder` is provided and differs, it gets updated. ### Aggregating event values via MCP KPI-style aggregation (single number — `sum`, `avg`, `count`, `rate`, `distinct_count`, `group_by`) is **not exposed over REST**, but it's available to MCP-compatible clients (Claude, Claude Code, Cursor, anything connecting through the public MCP connector) via three tools: | Tool | Scope | What it does | | ----------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `aggregate_events` | `mcp:read` | Single-number KPI or time/category series. Supports `dateGrouping` for time series and `group_by` for distributions. | | `query_events` | `mcp:read` | Table-style read — `mode='raw'` for individual rows, `mode='aggregate'` with `groupBy` + `metrics` for grouped reports. | | `render_dashboard_card` | `mcp:read` | Pass a `cardId` and get back the current rendered value, dispatching on the card's `cardType` and `config`. Supports number / rate / line / bar / pie / gauge / table / funnel. | These mirror the runtime endpoints `events/query` and `events/feed` (which **are** REST), but add the standalone aggregate that REST doesn't cover. See [MCP → Available MCP Tools](/core-concepts/mcp#available-mcp-tools-runflow-public-mcp) for the full catalogue. ### What's still portal-only Over **REST**, the KPI-style standalone aggregate (e.g. "sum of `amount` where `category = electronics` for the last 7 days") and the discovery endpoints (`get_event_names`, `get_event_properties`, `property-values`) remain behind `Auth0JwtGuard`. If you need them server-to-server today: * Use **MCP** (`aggregate_events` covers exactly this shape). * Or use the REST `events/query` endpoint with `mode: 'aggregate'` + `groupBy` — the same numbers come back as rows. ## Knowledge Ingestion (async) Background ingestion for large knowledge-base files (50k-row catalogs, big documents). The upload returns immediately with a job id; batched embedding runs server-side with checkpoint resume. The SDK's [`Knowledge.ingestFile`](/core-concepts/knowledge-rag#async-ingestion-for-large-files) (SDK ≥ `1.3.2`) and `rf kb upload` (CLI ≥ `0.3.22`) wrap these endpoints. ### Start an ingestion ```http theme={null} POST /api/v1/runtime/v1/vectors/files/ingest Content-Type: multipart/form-data ``` **Form fields:** | Field | Required | Description | | ---------------------------- | -------- | -------------------------------------------------- | | `file` | yes | The file (PDF, DOCX, TXT, MD, JSON, CSV). | | `vectorStore` | yes | Vector store **name**. | | `metadata` | no | JSON string merged into every document's metadata. | | `csvDelimiter` | no | CSV delimiter override (sniffed when omitted). | | `csvContentColumns` | no | Comma-separated columns to embed (default: all). | | `csvMetadataColumns` | no | Comma-separated columns copied to metadata. | | `hygieneStripHtml` | no | `true` to strip HTML tags and decode entities. | | `hygieneRemoveUrls` | no | `true` to remove URLs from content. | | `hygieneDropEmptyValues` | no | `true` to drop empty/placeholder values. | | `hygieneNormalizeWhitespace` | no | `true` to collapse repeated whitespace. | | `hygieneDedupeUnits` | no | `true` to drop duplicate rows/chunks. | CSV files are ingested **one document per row**. Response is `202 Accepted`: ```json theme={null} { "success": true, "jobId": "b8b2e1f0-...", "status": "queued", "statusUrl": "/runtime/v1/vectors/ingestion-jobs/b8b2e1f0-...", "vectorStore": "product-catalog", "message": "File accepted; processing in background. Poll statusUrl for progress." } ``` ### Poll a job ```http theme={null} GET /api/v1/runtime/v1/vectors/ingestion-jobs/:jobId ``` ```json theme={null} { "jobId": "b8b2e1f0-...", "status": "processing", "originalName": "catalog.csv", "totalChunks": 53068, "processedChunks": 31488, "progress": 0.5934, "documentId": null, "error": null, "startedAt": "2026-07-01T18:20:11.000Z", "completedAt": null, "createdAt": "2026-07-01T18:20:04.000Z" } ``` `status` transitions `queued → processing → completed | failed`. `documentId` is set on completion; `error` on failure. Ingestion resumes from a checkpoint if the worker restarts mid-job. ### List a store's jobs ```http theme={null} GET /api/v1/runtime/v1/vectors/stores/:name/ingestion-jobs?status=&limit=&offset= ``` Returns `{ "success": true, "vectorStore": "...", "jobs": [ ...job objects... ] }`, newest first (default limit 50, max 100). ## Errors The runtime API uses standard HTTP status codes: | Status | Meaning | | ------ | ------------------------------------------------------------------------------------------- | | `400` | Invalid input — usually a Zod/DTO validation failure. The body has a `message` array. | | `401` | Missing / invalid API key. | | `403` | API key is valid but lacks access to the requested resource (wrong tenant, disabled scope). | | `404` | Resource not found. For reviews, this also fires when the ID belongs to another tenant. | | `409` | Conflict — for execution reviews, the execution already has one. | | `422` | Semantic validation error (e.g. `comment` shorter than 10 chars). | | `5xx` | Server error. Safe to retry with backoff. | Error responses share this shape: ```json theme={null} { "statusCode": 409, "message": "Execution already has a review", "error": "Conflict" } ``` ## Next Steps Typed SDK wrapper for these endpoints Tracing, metrics, and the review feedback loop # Core Exports Source: https://docs.runflow.ai/api-reference/core Core exports from Runflow SDK ## Core Exports ```typescript theme={null} import { Agent, Runflow, openai, anthropic, bedrock } from '@runflow-ai/sdk'; ``` ## AgentInput.request When an agent is invoked via the **direct HTTP Agent API**, the platform auto-injects the raw HTTP request context on `input.request`. Useful for reading custom headers, cookies, query params, or path params without re-wrapping the agent in a controller. ```typescript theme={null} export async function main(input: AgentInput) { const traceId = input.request?.headers?.['x-trace-id']; const lang = input.request?.query?.lang ?? 'pt-BR'; // ... } ``` `input.request` is **not populated** for webhook-triggered invocations (the payload lives in `metadata` instead), scheduled jobs, or direct SDK calls. Always guard access with optional chaining. See [Core Types → AgentInput](/api-reference/types/core-types#agent-types) for the full field list. ## Next Steps View tools exports View workflow exports # Get oauth2authorize Source: https://docs.runflow.ai/api-reference/get-oauth2authorize /api-reference/openapi.json get /oauth2/authorize # Get oauth2callback Source: https://docs.runflow.ai/api-reference/get-oauth2callback /api-reference/openapi.json get /oauth2/callback # Get well knownjwksjson Source: https://docs.runflow.ai/api-reference/get-well-knownjwksjson /api-reference/openapi.json get /.well-known/jwks.json # Get well knownoauth authorization server Source: https://docs.runflow.ai/api-reference/get-well-knownoauth-authorization-server /api-reference/openapi.json get /.well-known/oauth-authorization-server # Get well knownoauth protected resource Source: https://docs.runflow.ai/api-reference/get-well-knownoauth-protected-resource /api-reference/openapi.json get /.well-known/oauth-protected-resource # Get well knownsecuritytxt Source: https://docs.runflow.ai/api-reference/get-well-knownsecuritytxt /api-reference/openapi.json get /.well-known/security.txt # API Reference Source: https://docs.runflow.ai/api-reference/introduction Complete API documentation for Runflow SDK ## Main Exports ```typescript theme={null} // Core import { Agent, Runflow, openai, anthropic, bedrock } from '@runflow-ai/sdk'; // Tools & Connectors import { createTool, createConnectorTool, connector } from '@runflow-ai/sdk'; // Workflows import { Workflow, createWorkflow, createStep, createAgentStep, createFunctionStep, createConnectorStep, WorkflowBuilder, } from '@runflow-ai/sdk'; // Standalone Modules import { Memory, Knowledge, RAG, LLM } from '@runflow-ai/sdk'; // Observability import { createTraceCollector, RunflowTraceCollector, RunflowTraceSpan, traced } from '@runflow-ai/sdk'; // API Client import { createRunflowAPIClient } from '@runflow-ai/sdk'; ``` ## Sub-path Imports ```typescript theme={null} // Core import { Agent } from '@runflow-ai/sdk/core'; import { Runflow } from '@runflow-ai/sdk/core'; import { openai, anthropic, bedrock } from '@runflow-ai/sdk/core'; // Tools import { createTool } from '@runflow-ai/sdk/tools'; // Connectors import { connector, createConnectorTool, loadConnector } from '@runflow-ai/sdk/connectors'; // Workflows import { createWorkflow, WorkflowBuilder } from '@runflow-ai/sdk/workflows'; // Memory import { Memory } from '@runflow-ai/sdk/memory'; // Knowledge import { Knowledge, RAG } from '@runflow-ai/sdk/knowledge'; // LLM import { LLM } from '@runflow-ai/sdk/llm'; // Observability import { createTraceCollector, traced } from '@runflow-ai/sdk/observability'; ``` ## Next Steps View core TypeScript types View memory types # Get connecthealth Source: https://docs.runflow.ai/api-reference/mcp-public-connector/get-connecthealth /api-reference/openapi.json get /connect/health # Public MCP endpoint capabilities (browser GET) Source: https://docs.runflow.ai/api-reference/mcp-public-connector/public-mcp-endpoint-capabilities-browser-get /api-reference/openapi.json get /connect # Public MCP endpoint (OAuth 2.1 Bearer token) Source: https://docs.runflow.ai/api-reference/mcp-public-connector/public-mcp-endpoint-oauth-21-bearer-token /api-reference/openapi.json post /connect # Observability Source: https://docs.runflow.ai/api-reference/observability Observability and tracking exports ## Observability Exports ```typescript theme={null} import { createTraceCollector, RunflowTraceCollector, RunflowTraceSpan, traced } from '@runflow-ai/sdk'; ``` ## Conversation Messages ```typescript theme={null} import { message } from '@runflow-ai/sdk/observability'; ``` ### `message(data, options?)` Emit a `conversation_message` trace. The portal renders these as chat bubbles and uses them to populate the thread sidebar preview. See [Conversation Messages](/core-concepts/observability#conversation-messages) for the full guide and examples. ```typescript theme={null} message({ role: 'user', content: 'Quais acomodações?' }); message({ role: 'assistant', content: 'Oferecemos duas opções...' }); ``` **Parameters (`MessageData`):** | Name | Type | Description | | ---------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | `role` | `'user' \| 'assistant' \| 'system' \| 'tool' \| string` | Speaker. Custom strings are accepted. | | `content` | `string \| Record` | Plain text or a structured object with a `type` field (e.g. `{ type: 'buttons', items: [...] }`). | | `metadata` | `Record` | Optional extra fields (citations, confidence, custom flags). | | `parentId` | `string` | Optional explicit parent span's `traceId`. Falls back to the active `startSpan()` context. | **`LogOptions`:** | Name | Type | Description | | ---------- | -------- | --------------------------------------------------------------------------- | | `parentId` | `string` | Same as `data.parentId`. Provided for symmetry with `log()` / `logError()`. | Available since `@runflow-ai/sdk@1.1.10`. ## Code-first Metrics ```typescript theme={null} import { metrics, MetricsRegistry } from '@runflow-ai/sdk/observability'; ``` ### `metrics` (singleton) Process-wide `MetricsRegistry` instance. `import { metrics }` returns the same object across modules, mirroring how `track()` and `identify()` share state. ### `metrics.defineTab(input)` Idempotent on `name` — re-registering updates the `sortOrder`. ```typescript theme={null} metrics.defineTab({ name: 'Vendas', sortOrder: 0 }); ``` ### `metrics.defineCard(input)` Validates synchronously via shared Zod schemas. Throws on invalid `cardType`, invalid `gridLayout`, or duplicate `(cardType, title)` pairs. Legacy aliases (e.g. `tableMode → mode`) are auto-normalized before validation. ```typescript theme={null} metrics.defineCard({ tab: 'Vendas', title: 'Funil de checkout', cardType: 'funnel', config: { funnelMode: 'multi_event', steps: [ { eventName: 'cart_open', label: 'Carrinho aberto' }, { eventName: 'checkout_started', label: 'Início checkout' }, { eventName: 'sale', label: 'Pagamento' }, ], }, gridLayout: { x: 0, y: 0, w: 6, h: 5 }, }); ``` ### `metrics.sync(options?)` Two-phase upsert against the runtime endpoints. Returns a tally: ```typescript theme={null} const result = await metrics.sync(); // { agentId, tabs: { created, updated, total }, cards: { created, updated, failed, total } } ``` **`SyncOptions`:** | Name | Type | Default | Description | | --------- | --------- | ---------------------- | ----------------------------------------------- | | `agentId` | `string` | `RUNFLOW_AGENT_ID` env | Override the target agent. | | `baseUrl` | `string` | `RUNFLOW_API_URL` env | Override the API host. | | `apiKey` | `string` | `RUNFLOW_API_KEY` env | Override the API key. | | `strict` | `boolean` | `false` | Throw on any tab/card error instead of warning. | Each card carries a deterministic `idempotencyKey = ${cardType}:${title}:${tab ?? ''}` so repeated `sync()` calls are safe. See [Metrics Registry](/core-concepts/metrics) for the full guide. ## Execution Reviews ```typescript theme={null} import { Reviews } from '@runflow-ai/sdk'; ``` ### `new Reviews(options?)` Programmatic access to execution reviews. Requires an underlying `RunflowAPIClient` exposing the `reviews` namespace (SDK ≥ `1.1.13`). ```typescript theme={null} const reviews = new Reviews(); // or with a custom client: const reviews = new Reviews({ apiClient: customClient }); ``` **Methods:** | Method | Signature | Description | | ------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `create` | `(args: CreateExecutionReviewArgs) => Promise` | Create a review. `comment` must be ≥ 10 chars. Optional owner (`assignedToUserId`), deadline (`dueAt`) and `source` since `1.5.1`. | | `checkHasReview` | `(executionId: string) => Promise` | Idempotent check before `create()`. | | `list` | `(filters?: ListReviewsFilters) => Promise` | List with filters — including `queue` (`active` / `history`), `source` (`human` / `sdk` / `policy` / `legacy`) and `assignedToUserId` since `1.5.1`. Backend caps `limit` at 100 (defaults to 50). | | `get` | `(reviewId: string) => Promise` | Fetch a single review. | | `update` | `(reviewId, args: UpdateExecutionReviewArgs) => Promise` | Partial update — also `assignedToUserId`, `dueAt` and `disposition` (`fixed` / `false_positive` / `duplicate` / `no_action`) since `1.5.1`. Setting `status: 'resolved'` auto-stamps `resolvedBy` / `resolvedAt`. | | `delete` | `(reviewId: string) => Promise` | Hard delete. | | `stats` | `(filters: { agentId }) => Promise` | Aggregate counts + `avgResolutionHours`. | | `exportForTraining` | `(filters: ExportForTrainingFilters) => Promise` | Export as OpenAI conversational fine-tuning examples. | **Typed errors (all extend `ReviewsError`):** | Class | Status | When | | -------------------------- | ------ | ------------------------------------------------- | | `ReviewAlreadyExistsError` | 409 | Execution already has a review. | | `ReviewNotFoundError` | 404 | reviewId not found (or wrong tenant). | | `ReviewsError` | any | Other failures — exposes `status` and raw `body`. | See [Execution Reviews](/core-concepts/observability#execution-reviews) for the feedback-loop pattern. ## Business Events Tracking ```typescript theme={null} import { track, flushTrackEvents } from '@runflow-ai/sdk/observability'; ``` ### `track(eventName, properties?, options?)` Emit a business event for the portal dashboard. ```typescript theme={null} track('alert_received', { company: 'NW', severity: 'High' }); ``` **Parameters:** | Name | Type | Description | | ------------ | --------------------- | ---------------------------------- | | `eventName` | `string` | Event name (e.g. `'order_placed'`) | | `properties` | `Record` | Key-value event data | | `options` | `TrackOptions` | Optional overrides | **`TrackOptions`:** | Name | Type | Description | | ------------- | -------- | ------------------------------------ | | `threadId` | `string` | Override auto-resolved thread ID | | `executionId` | `string` | Override auto-resolved execution ID | | `timestamp` | `string` | ISO-8601 timestamp (defaults to now) | ### `flushTrackEvents()` Manually flush all buffered events. Returns a `Promise`. ```typescript theme={null} await flushTrackEvents(); ``` ## Next Steps View API client exports View standalone modules # Post oauth2register Source: https://docs.runflow.ai/api-reference/post-oauth2register /api-reference/openapi.json post /oauth2/register # Post oauth2revoke Source: https://docs.runflow.ai/api-reference/post-oauth2revoke /api-reference/openapi.json post /oauth2/revoke # Post oauth2token Source: https://docs.runflow.ai/api-reference/post-oauth2token /api-reference/openapi.json post /oauth2/token # Create new agent (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--agents-management/create-new-agent-clisdk /api-reference/openapi.json post /api/v1/runtime/agents Creates a new AI agent via CLI/SDK. # Delete agent (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--agents-management/delete-agent-clisdk /api-reference/openapi.json delete /api/v1/runtime/agents/{id} Deletes an agent via CLI/SDK. # Deploy agent with code changes Source: https://docs.runflow.ai/api-reference/runtime-api--agents-management/deploy-agent-with-code-changes /api-reference/openapi.json patch /api/v1/runtime/agents/{id}/deploy Deploys agent with local code changes from CLI. # Duplicate agent (Clone on server) Source: https://docs.runflow.ai/api-reference/runtime-api--agents-management/duplicate-agent-clone-on-server /api-reference/openapi.json post /api/v1/runtime/agents/{id}/clone Creates a duplicate of an existing agent on the server. # Get agent by ID (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--agents-management/get-agent-by-id-clisdk /api-reference/openapi.json get /api/v1/runtime/agents/{id} Retrieves detailed information about a specific agent. # Get agent code/repository (Clone) Source: https://docs.runflow.ai/api-reference/runtime-api--agents-management/get-agent-coderepository-clone /api-reference/openapi.json get /api/v1/runtime/agents/{id}/clone Retrieves agent source code and files for cloning to local environment. Includes .runflow metadata file. # Get agent engine information (public) Source: https://docs.runflow.ai/api-reference/runtime-api--agents-management/get-agent-engine-information-public /api-reference/openapi.json get /api/v1/runtime/agents/{id}/engine-info Returns which execution engine the agent uses - used for routing between old and new engines # Get agent repository tree (CLI) Source: https://docs.runflow.ai/api-reference/runtime-api--agents-management/get-agent-repository-tree-cli /api-reference/openapi.json get /api/v1/runtime/agents/{id}/repository/tree Retrieves agent repository file tree for cloning. # List all agents (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--agents-management/list-all-agents-clisdk /api-reference/openapi.json get /api/v1/runtime/agents Retrieves a paginated list of all agents for CLI/SDK usage. # Promote agent from staging to production Source: https://docs.runflow.ai/api-reference/runtime-api--agents-management/promote-agent-from-staging-to-production /api-reference/openapi.json post /api/v1/runtime/agents/{id}/promote Merges staging code to production and rebuilds. # Update agent (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--agents-management/update-agent-clisdk /api-reference/openapi.json put /api/v1/runtime/agents/{id} Updates an existing agent via CLI/SDK. # Discover tenant from API key (CLI Login) Source: https://docs.runflow.ai/api-reference/runtime-api--authentication/discover-tenant-from-api-key-cli-login /api-reference/openapi.json post /api/v1/runtime/auth/discover Returns tenant information for the provided API key. Used by CLI for authentication. # List all connectors (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--connectors-management/list-all-connectors-clisdk /api-reference/openapi.json get /api/v1/runtime/connectors Retrieves a paginated list of all connectors for CLI/SDK usage. # Execute Connector Source: https://docs.runflow.ai/api-reference/runtime-api--connectors/execute-connector /api-reference/openapi.json post /api/v1/runtime/v1/connectors/{connector} Execute external API # Get resource schema Source: https://docs.runflow.ai/api-reference/runtime-api--connectors/get-resource-schema /api-reference/openapi.json get /api/v1/runtime/v1/connectors/{connectorName}/resources/{resourceName}/schema Get resource schema # Delete file by provider/fileId Source: https://docs.runflow.ai/api-reference/runtime-api--core/delete-file-by-providerfileid /api-reference/openapi.json delete /api/v1/runtime/v1/files/{fileId} # Generate image using DALL-E or other providers Source: https://docs.runflow.ai/api-reference/runtime-api--core/generate-image-using-dall-e-or-other-providers /api-reference/openapi.json post /api/v1/runtime/v1/images/generate # LLM Chat Completion Source: https://docs.runflow.ai/api-reference/runtime-api--core/llm-chat-completion /api-reference/openapi.json post /api/v1/runtime/v1/chat # LLM Chat Streaming Source: https://docs.runflow.ai/api-reference/runtime-api--core/llm-chat-streaming /api-reference/openapi.json post /api/v1/runtime/v1/chat/stream Streams text chunks via plain HTTP (Transfer-Encoding: chunked). # Log Entry Source: https://docs.runflow.ai/api-reference/runtime-api--core/log-entry /api-reference/openapi.json post /api/v1/runtime/v1/logs # Runtime API health check Source: https://docs.runflow.ai/api-reference/runtime-api--core/runtime-api-health-check /api-reference/openapi.json get /api/v1/runtime/v1/health # Transcribe audio to text using Whisper Source: https://docs.runflow.ai/api-reference/runtime-api--core/transcribe-audio-to-text-using-whisper /api-reference/openapi.json post /api/v1/runtime/v1/transcribe # Upload file for provider (returns fileId) Source: https://docs.runflow.ai/api-reference/runtime-api--core/upload-file-for-provider-returns-fileid /api-reference/openapi.json post /api/v1/runtime/v1/files # Get credential by name or ID (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--credentials-management/get-credential-by-name-or-id-clisdk /api-reference/openapi.json get /api/v1/runtime/credentials/{nameOrId} Retrieves a credential by its name or UUID. # List all credentials (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--credentials-management/list-all-credentials-clisdk /api-reference/openapi.json get /api/v1/runtime/credentials Retrieves a paginated list of all credentials for CLI/SDK usage. # Get Credential Source: https://docs.runflow.ai/api-reference/runtime-api--credentials/get-credential /api-reference/openapi.json get /api/v1/runtime/v1/credentials/{credentialId} # Get Credential by Name Source: https://docs.runflow.ai/api-reference/runtime-api--credentials/get-credential-by-name /api-reference/openapi.json get /api/v1/runtime/v1/credentials/getByName/{name} # List dashboard cards for an agent (CLI) Source: https://docs.runflow.ai/api-reference/runtime-api--dashboard-cards/list-dashboard-cards-for-an-agent-cli /api-reference/openapi.json get /api/v1/runtime/observability/dashboard-cards Returns all dashboard cards configured for the given agent. # Upsert a dashboard card (CLI metrics sync) Source: https://docs.runflow.ai/api-reference/runtime-api--dashboard-cards/upsert-a-dashboard-card-cli-metrics-sync /api-reference/openapi.json post /api/v1/runtime/observability/dashboard-cards Creates or updates a dashboard card by (agent_id, event_name). Idempotent — safe to call on every deploy. # List dashboard tabs for an agent (CLI) Source: https://docs.runflow.ai/api-reference/runtime-api--dashboard-tabs/list-dashboard-tabs-for-an-agent-cli /api-reference/openapi.json get /api/v1/runtime/observability/dashboard-tabs Returns all tabs configured for the given agent, ordered by sortOrder ASC. # Upsert a dashboard tab (CLI metrics sync) Source: https://docs.runflow.ai/api-reference/runtime-api--dashboard-tabs/upsert-a-dashboard-tab-cli-metrics-sync /api-reference/openapi.json post /api/v1/runtime/observability/dashboard-tabs Creates or updates a dashboard tab by (agent_id, name). Idempotent — safe to call on every deploy. # List all datasources (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--datasources-management/list-all-datasources-clisdk /api-reference/openapi.json get /api/v1/runtime/datasources Retrieves a paginated list of all datasources for CLI/SDK usage. # Generate embeddings Source: https://docs.runflow.ai/api-reference/runtime-api--embeddings/generate-embeddings /api-reference/openapi.json post /api/v1/runtime/v1/embeddings Generate embeddings for an array of texts. **Authentication:** - Requires `x-api-key` header with valid API key or internal service key. - Requires `x-runflow-tenant-id` header for tenant identification. **Credential Resolution:** - Credentials are resolved automatically from tenant's LLM Providers configuration. - Use 'providerName' to specify which provider configuration to use. **Supported Providers:** - `openai`: text-embedding-3-small, text-embedding-3-large, text-embedding-ada-002 - `azure_openai`: Azure OpenAI deployments - `cohere`: embed-english-v3.0, embed-multilingual-v3.0 **Example Request:** ```json { "input": ["Hello, world!", "How are you?"], "model": "text-embedding-3-small", "dimensions": 1536 } ``` # List supported embedding models Source: https://docs.runflow.ai/api-reference/runtime-api--embeddings/list-supported-embedding-models /api-reference/openapi.json get /api/v1/runtime/v1/embeddings/models Returns all supported embedding models with their providers and default dimensions. # Ingest events (SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--events/ingest-events-sdk /api-reference/openapi.json post /api/v1/runtime/v1/observability/events Push business events from the SDK or any server-to-server caller. Processed asynchronously — the endpoint returns 200 immediately and the events become queryable within a few seconds. # Query events as a table (SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--events/query-events-as-a-table-sdk /api-reference/openapi.json post /api/v1/runtime/v1/observability/events/query Returns event rows for the given agent and eventName. `mode: 'raw'` returns individual events; `mode: 'aggregate'` groups rows by a property key and applies the listed metrics (count/sum/avg/min/max). Filters, date ranges, sort and pagination are all server-side. # Recent events feed (SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--events/recent-events-feed-sdk /api-reference/openapi.json get /api/v1/runtime/v1/observability/events/feed Returns the most recent events for an agent, ordered by timestamp DESC. Optionally filter by `eventName`. Paginated via `limit` (max 100) and `offset`. # Get execution by ID (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--executions-management/get-execution-by-id-clisdk /api-reference/openapi.json get /api/v1/runtime/executions/{id} Retrieves detailed information about a specific execution. # List all executions (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--executions-management/list-all-executions-clisdk /api-reference/openapi.json get /api/v1/runtime/executions Retrieves a paginated list of all executions for CLI/SDK usage. # Clear namespace (SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--key-value-store/clear-namespace-sdk /api-reference/openapi.json delete /api/v1/runtime/v1/kv/{namespace} Deletes all keys in a namespace. Idempotent. # Delete key (SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--key-value-store/delete-key-sdk /api-reference/openapi.json delete /api/v1/runtime/v1/kv/{namespace}/item/{key} Deletes a single key. Idempotent. # Get all entries (SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--key-value-store/get-all-entries-sdk /api-reference/openapi.json get /api/v1/runtime/v1/kv/{namespace}/entries Returns all entries (keys + values) in a namespace. Supports the same glob pattern as /keys. # Get value (SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--key-value-store/get-value-sdk /api-reference/openapi.json get /api/v1/runtime/v1/kv/{namespace}/item/{key} Gets a single value. Returns found=false when the key is missing or expired. # List keys (SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--key-value-store/list-keys-sdk /api-reference/openapi.json get /api/v1/runtime/v1/kv/{namespace}/keys Lists keys in a namespace. Supports glob pattern matching, e.g. pattern=cart:*:items. # List namespaces (SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--key-value-store/list-namespaces-sdk /api-reference/openapi.json get /api/v1/runtime/v1/kv Lists all key-value namespaces for the tenant with key counts. # Set value (SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--key-value-store/set-value-sdk /api-reference/openapi.json put /api/v1/runtime/v1/kv/{namespace}/item/{key} Sets a value with optional TTL (seconds). The namespace is created implicitly on first write. # Append Memory Source: https://docs.runflow.ai/api-reference/runtime-api--memory/append-memory /api-reference/openapi.json post /api/v1/runtime/v1/memory/{memoryId}/append # Clear Memory Source: https://docs.runflow.ai/api-reference/runtime-api--memory/clear-memory /api-reference/openapi.json delete /api/v1/runtime/v1/memory/{memoryId} # Get Memory Source: https://docs.runflow.ai/api-reference/runtime-api--memory/get-memory /api-reference/openapi.json get /api/v1/runtime/v1/memory/{memoryId} # Memory Cache Health Check Source: https://docs.runflow.ai/api-reference/runtime-api--memory/memory-cache-health-check /api-reference/openapi.json get /api/v1/runtime/v1/memory/cache/health # Search Memory Source: https://docs.runflow.ai/api-reference/runtime-api--memory/search-memory /api-reference/openapi.json post /api/v1/runtime/v1/memory/{memoryId}/search # Set Memory Source: https://docs.runflow.ai/api-reference/runtime-api--memory/set-memory /api-reference/openapi.json put /api/v1/runtime/v1/memory/{memoryId} # Summarize Memory Source: https://docs.runflow.ai/api-reference/runtime-api--memory/summarize-memory /api-reference/openapi.json post /api/v1/runtime/v1/memory/{memoryId}/summarize # Check if execution already has a review (SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--observability/check-if-execution-already-has-a-review-sdk /api-reference/openapi.json get /api/v1/runtime/v1/observability/executions/{executionId}/reviews/exists # Create a review for an execution (SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--observability/create-a-review-for-an-execution-sdk /api-reference/openapi.json post /api/v1/runtime/v1/observability/executions/{executionId}/reviews # Create traces from SDK Source: https://docs.runflow.ai/api-reference/runtime-api--observability/create-traces-from-sdk /api-reference/openapi.json post /api/v1/runtime/v1/observability/traces # Delete a review (SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--observability/delete-a-review-sdk /api-reference/openapi.json delete /api/v1/runtime/v1/observability/reviews/{reviewId} # Export reviews as training data (SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--observability/export-reviews-as-training-data-sdk /api-reference/openapi.json get /api/v1/runtime/v1/observability/reviews/export/training-data # Get a specific review (SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--observability/get-a-specific-review-sdk /api-reference/openapi.json get /api/v1/runtime/v1/observability/reviews/{reviewId} # Get reviews statistics (SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--observability/get-reviews-statistics-sdk /api-reference/openapi.json get /api/v1/runtime/v1/observability/reviews/stats # List reviews with filters (SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--observability/list-reviews-with-filters-sdk /api-reference/openapi.json get /api/v1/runtime/v1/observability/reviews # Update a review (SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--observability/update-a-review-sdk /api-reference/openapi.json patch /api/v1/runtime/v1/observability/reviews/{reviewId} # Get prompt by name or ID (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--prompts-management/get-prompt-by-name-or-id-clisdk /api-reference/openapi.json get /api/v1/runtime/prompts/{nameOrId} Retrieves a prompt by its name or UUID. Optionally resolve by environment. # Render prompt with variables (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--prompts-management/render-prompt-with-variables-clisdk /api-reference/openapi.json post /api/v1/runtime/prompts/{nameOrId}/render Renders a prompt template with provided variables. # Create Prompt Source: https://docs.runflow.ai/api-reference/runtime-api--prompts/create-prompt /api-reference/openapi.json post /api/v1/runtime/v1/prompts Create a tenant-specific prompt # Delete Prompt Source: https://docs.runflow.ai/api-reference/runtime-api--prompts/delete-prompt /api-reference/openapi.json delete /api/v1/runtime/v1/prompts/{nameOrId} Delete a tenant-specific prompt (cannot delete global prompts) # Get Prompt by Name or ID Source: https://docs.runflow.ai/api-reference/runtime-api--prompts/get-prompt-by-name-or-id /api-reference/openapi.json get /api/v1/runtime/v1/prompts/{nameOrId} # List Prompts Source: https://docs.runflow.ai/api-reference/runtime-api--prompts/list-prompts /api-reference/openapi.json get /api/v1/runtime/v1/prompts # Update Prompt Source: https://docs.runflow.ai/api-reference/runtime-api--prompts/update-prompt /api-reference/openapi.json patch /api/v1/runtime/v1/prompts/{nameOrId} Update a tenant-specific prompt (cannot update global prompts) # Cancel (delete) a schedule Source: https://docs.runflow.ai/api-reference/runtime-api--schedules/cancel-delete-a-schedule /api-reference/openapi.json delete /api/v1/runtime/v1/schedules/{id} # Create a schedule Source: https://docs.runflow.ai/api-reference/runtime-api--schedules/create-a-schedule /api-reference/openapi.json post /api/v1/runtime/v1/schedules # List schedules for the current agent Source: https://docs.runflow.ai/api-reference/runtime-api--schedules/list-schedules-for-the-current-agent /api-reference/openapi.json get /api/v1/runtime/v1/schedules # Update a schedule Source: https://docs.runflow.ai/api-reference/runtime-api--schedules/update-a-schedule /api-reference/openapi.json patch /api/v1/runtime/v1/schedules/{id} # Add message to session (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--sessions-management/add-message-to-session-clisdk /api-reference/openapi.json post /api/v1/runtime/sessions/{id}/messages Adds a message to a session history. # Clear session (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--sessions-management/clear-session-clisdk /api-reference/openapi.json delete /api/v1/runtime/sessions/{id} Clears/deletes a session and its history. # Get session history (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--sessions-management/get-session-history-clisdk /api-reference/openapi.json get /api/v1/runtime/sessions/{id}/history Retrieves the message history for a session. # List sessions (SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--sessions-management/list-sessions-sdk /api-reference/openapi.json get /api/v1/runtime/sessions List sessions for the current agent. Returns last message for each session. # Set session status (SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--sessions-management/set-session-status-sdk /api-reference/openapi.json patch /api/v1/runtime/sessions/{id}/status # List all triggers (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--triggers-management/list-all-triggers-clisdk /api-reference/openapi.json get /api/v1/runtime/triggers Retrieves a paginated list of all triggers for CLI/SDK usage. # Create new user (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--users-management/create-new-user-clisdk /api-reference/openapi.json post /api/v1/runtime/users Creates a new user via CLI/SDK. # Delete user (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--users-management/delete-user-clisdk /api-reference/openapi.json delete /api/v1/runtime/users/{id} Deletes a user via CLI/SDK. # Get user by ID (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--users-management/get-user-by-id-clisdk /api-reference/openapi.json get /api/v1/runtime/users/{id} Retrieves detailed information about a specific user. # List all users (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--users-management/list-all-users-clisdk /api-reference/openapi.json get /api/v1/runtime/users Retrieves a paginated list of all users for CLI/SDK usage. # Update user (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--users-management/update-user-clisdk /api-reference/openapi.json patch /api/v1/runtime/users/{id} Updates an existing user via CLI/SDK. # Add Document Source: https://docs.runflow.ai/api-reference/runtime-api--vector-search/add-document /api-reference/openapi.json post /api/v1/runtime/v1/vectors/documents Add document to vector store # Add Document to Store Source: https://docs.runflow.ai/api-reference/runtime-api--vector-search/add-document-to-store /api-reference/openapi.json post /api/v1/runtime/v1/vectors/stores/{name}/documents Add document to specific vector store # Create Vector Store Source: https://docs.runflow.ai/api-reference/runtime-api--vector-search/create-vector-store /api-reference/openapi.json post /api/v1/runtime/v1/vectors/stores Create a new vector store # Delete Document Source: https://docs.runflow.ai/api-reference/runtime-api--vector-search/delete-document /api-reference/openapi.json delete /api/v1/runtime/v1/vectors/documents/{documentId} Delete a document from vector store # List Documents Source: https://docs.runflow.ai/api-reference/runtime-api--vector-search/list-documents /api-reference/openapi.json get /api/v1/runtime/v1/vectors/stores/{name}/documents List documents from a vector store with optional metadata filters # Update Vector Store Source: https://docs.runflow.ai/api-reference/runtime-api--vector-search/update-vector-store /api-reference/openapi.json patch /api/v1/runtime/v1/vectors/stores/{name} Update vector store name, description, or convert STRUCTURED → KNOWLEDGE # Upload File to Vector Store Source: https://docs.runflow.ai/api-reference/runtime-api--vector-search/upload-file-to-vector-store /api-reference/openapi.json post /api/v1/runtime/v1/vectors/files/upload Upload and process a file, adding it to the specified vector store # Vector Search Source: https://docs.runflow.ai/api-reference/runtime-api--vector-search/vector-search /api-reference/openapi.json post /api/v1/runtime/v1/vectors/search Search in vector stores # Create vector store (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--vector-stores-management/create-vector-store-clisdk /api-reference/openapi.json post /api/v1/runtime/vector-stores Creates a new vector store with specified embedding configuration. # Delete document from vector store (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--vector-stores-management/delete-document-from-vector-store-clisdk /api-reference/openapi.json delete /api/v1/runtime/vector-stores/{id}/documents/{documentId} Removes a specific document from the vector store. # Delete vector store (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--vector-stores-management/delete-vector-store-clisdk /api-reference/openapi.json delete /api/v1/runtime/vector-stores/{id} Deletes a vector store and all its documents. # Get vector store by ID (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--vector-stores-management/get-vector-store-by-id-clisdk /api-reference/openapi.json get /api/v1/runtime/vector-stores/{id} Retrieves detailed information about a specific vector store including stats. # List all vector stores (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--vector-stores-management/list-all-vector-stores-clisdk /api-reference/openapi.json get /api/v1/runtime/vector-stores Retrieves a list of all vector stores for CLI/SDK usage. # List documents in vector store (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--vector-stores-management/list-documents-in-vector-store-clisdk /api-reference/openapi.json get /api/v1/runtime/vector-stores/{id}/documents Retrieves all documents from a specific vector store. # List embedding configurations (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--vector-stores-management/list-embedding-configurations-clisdk /api-reference/openapi.json get /api/v1/runtime/vector-stores/embedding-configs Returns available embedding configurations for creating vector stores. # Search in vector store (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--vector-stores-management/search-in-vector-store-clisdk /api-reference/openapi.json post /api/v1/runtime/vector-stores/{id}/search Performs semantic search in a specific vector store. # Update vector store (CLI/SDK) Source: https://docs.runflow.ai/api-reference/runtime-api--vector-stores-management/update-vector-store-clisdk /api-reference/openapi.json patch /api/v1/runtime/vector-stores/{id} Updates vector store information. # Standalone Modules Source: https://docs.runflow.ai/api-reference/standalone-modules Standalone module exports ## Standalone Modules ```typescript theme={null} // Single-agent (caller's own scope) import { Memory, KV, Knowledge, RAG, LLM } from '@runflow-ai/sdk'; // Cross-agent (operate on another agent's data within the tenant) import { Agents } from '@runflow-ai/sdk/agents'; import { Executions } from '@runflow-ai/sdk/executions'; import { Threads } from '@runflow-ai/sdk/threads'; import { MemoryAdmin } from '@runflow-ai/sdk/memory-admin'; // Reviews (production execution reviews — single or cross-agent) import { Reviews } from '@runflow-ai/sdk/reviews'; ``` ## Single-agent modules Conversation history for the caller's own agent. Persistent key-value storage with TTL, namespaces and pattern search. Vector search and retrieval-augmented generation. Direct LLM calls outside the Agent abstraction. Versioned prompt templates. ## Cross-agent modules The cross-agent SDK lets one agent operate on another agent's data within the same tenant. All operations are tenant-scoped — cross-tenant access returns 404 (existence never leaked). See [Cross-Agent SDK](/core-concepts/cross-agent) for the full guide. Invoke other agents (`invoke` / `invokeAsync`), list, get. Read execution rows and paginated trace trees. Walk grouped conversations. `getFullThread` returns thread + executions + traces in one call. Manage another agent's memory: get / set / append / clear / search / list / summarize. ## Reviews Create, list, resolve, and dismiss production execution reviews. Used by reviewer agents and human reviewers in the portal. ## Next Steps Full guide to cross-agent primitives. View observability exports View API client exports # Tools & Connectors Source: https://docs.runflow.ai/api-reference/tools-connectors Tools, connectors, web search, and schedule exports ## Tools & Connectors Exports ```typescript theme={null} // Custom tools import { createTool } from '@runflow-ai/sdk'; // Connectors import { createConnectorTool, connector } from '@runflow-ai/sdk'; // Web Search import { webSearch, createWebSearchTool } from '@runflow-ai/sdk'; import type { WebSearchConfig, WebSearchResponse, WebSearchResult, WebSearchProvider } from '@runflow-ai/sdk'; // Schedule import { schedule, createScheduleTools } from '@runflow-ai/sdk'; import type { ScheduleCreateConfig, ScheduleUpdateConfig, ScheduleResponse } from '@runflow-ai/sdk'; ``` ## Connectors | Export | Type | Description | | --------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `connector(name, resource, data, options?)` | Function | Direct invocation. `name` resolves to an instance slug first, then falls back to a **template slug** when the template has `authRequired = false` (e.g. JSONPlaceholder, public OpenAPI imports). | | `createConnectorTool({ connector, resource, ... })` | Function | Wrap a connector as an agent tool. Same instance-then-template resolution. | | `loadConnector(slug)` | Function | Force-preload a connector schema before first call. | See [Connectors → Public APIs](/core-concepts/connectors#public-apis-call-a-template-directly-no-instance) for the no-auth template shortcut. ## Web Search | Export | Type | Description | | ------------------------------ | -------- | ------------------------------- | | `webSearch(query, config?)` | Function | Programmatic web search | | `createWebSearchTool(config?)` | Function | Create a search tool for agents | ## Schedule | Export | Type | Description | | ----------------------------- | -------- | ---------------------------------- | | `schedule.create(config)` | Function | Create a scheduled execution | | `schedule.list()` | Function | List active schedules | | `schedule.update(id, config)` | Function | Update a schedule | | `schedule.cancel(id)` | Function | Cancel a schedule | | `createScheduleTools()` | Function | Create 4 schedule tools for agents | ## Next Steps Detailed web search documentation Detailed schedule documentation View core exports View workflow exports # Core Types Source: https://docs.runflow.ai/api-reference/types/core-types Core TypeScript types for Runflow SDK ## Agent Types ```typescript theme={null} interface AgentInput { message: string; file?: MediaFile; companyId?: string; userId?: string; sessionId?: string; executionId?: string; threadId?: string; entityType?: string; entityValue?: string; channel?: string; messages?: Message[]; metadata?: Record; /** * Raw HTTP request context — auto-injected by the platform only when * the agent is invoked via the direct HTTP Agent API. * * Not populated for webhook-triggered invocations (payload lives in * `metadata` instead), scheduled jobs, or direct SDK calls. * * Always guard access with optional chaining: `input.request?.body`. */ request?: { body?: any; headers?: Record; cookies?: Record; query?: Record; params?: Record; }; } interface AgentOutput { message: string; metadata?: Record; } interface AgentConfig { name: string; instructions: string | PromptRef; model: ModelProvider; modelConfig?: ModelConfig; tools?: Record; maxToolIterations?: number; rag?: RAGConfig; memory?: MemoryConfig; media?: MediaConfig; streaming?: StreamingConfig; agents?: Record; debug?: boolean | DebugConfig; observability?: ObservabilityMode | ObservabilityConfig; } interface ModelConfig { temperature?: number; maxTokens?: number; topP?: number; frequencyPenalty?: number; presencePenalty?: number; stop?: string[]; seed?: number; } interface DebugConfig { enabled: boolean; logMessages?: boolean; logLLMCalls?: boolean; logToolCalls?: boolean; logRAG?: boolean; logMemory?: boolean; truncateAt?: number; } ``` ## Next Steps View memory types View RAG types # Memory Types Source: https://docs.runflow.ai/api-reference/types/memory-types TypeScript types for Memory system ## Memory Types ```typescript theme={null} interface MemoryConfig { type?: 'conversation' | 'entity' | 'summary' | 'hybrid'; maxTurns?: number; // Max conversation turns to keep maxTokens?: number; // Max tokens to keep summarizeAfter?: number; // Trigger summary after N turns summarizePrompt?: string; // Custom prompt for summarization summarizeModel?: ModelProvider; // Custom model for summarization memoryKey?: string; // Custom memory key } interface MemoryData { sessionId: string; messages: MemoryMessage[]; entities: Record; summary?: string; metadata: { createdAt: Date; updatedAt: Date; totalTurns: number; totalTokens: number; }; } interface MemoryMessage { role: 'user' | 'assistant' | 'system'; content: string; timestamp: Date; metadata?: { toolsUsed?: string[]; ragUsed?: boolean; model?: string; tokens?: number; }; } ``` ## Next Steps View RAG types View tool types # RAG Types Source: https://docs.runflow.ai/api-reference/types/rag-types TypeScript types for RAG/Knowledge system ## RAG Types ```typescript theme={null} interface RAGConfig { vectorStore?: string; vectorStores?: Array<{ id: string; name: string; threshold?: number; k?: number; description?: string; searchPrompt?: string; }>; threshold?: number; k?: number; searchPrompt?: string; toolDescription?: string; // Advanced features onResultsFound?: (results: SearchResult[], query: string) => Promise | SearchResult[]; rerank?: RerankConfig; } interface RerankConfig { enabled?: boolean; strategy?: 'reciprocal-rank-fusion' | 'score-boost' | 'metadata-weight' | 'custom'; boostKeywords?: string[]; metadataField?: string; customScore?: (result: SearchResult, query: string) => number; } interface SearchResult { id?: string; content: string; score: number; metadata?: Record; } ``` ## Next Steps View tool types View workflow types # Tool Types Source: https://docs.runflow.ai/api-reference/types/tool-types TypeScript types for Tools ## Tool Types ```typescript theme={null} interface ToolConfig { id: string; description: string; inputSchema: z.ZodSchema; outputSchema?: z.ZodSchema; execute: (params: { context: TInput; runflow: RunflowAPIClient; projectId: string; }) => Promise; } interface RunflowTool { id: string; name?: string; description: string; parameters: Record; execute: (params: any, context: ToolContext) => Promise; } interface ToolContext { projectId: string; companyId: string; userId?: string; sessionId?: string; runflowAPI: RunflowAPIClient; } ``` ## Next Steps View workflow types View trace types # Trace Types Source: https://docs.runflow.ai/api-reference/types/trace-types TypeScript types for Observability/Tracing ## Trace Types ```typescript theme={null} interface TraceData { traceId: string; parentTraceId?: string; projectId: string; executionId?: string; threadId?: string; type: TraceType; operation: string; input: any; output?: any; status: 'success' | 'error' | 'timeout' | 'cancelled'; error?: string; startTime: Date; endTime?: Date; duration: number; metadata: TraceMetadata; costs?: TraceCosts; } type TraceType = | 'agent_execution' | 'workflow_execution' | 'workflow_step' | 'tool_call' | 'connector_call' | 'vector_search' | 'memory_operation' | 'llm_call' | 'streaming_session'; interface TraceCosts { tokens?: { input: number; output: number; total: number; }; costs?: { inputCost: number; outputCost: number; totalCost: number; currency: string; }; } ``` ## Next Steps View core types Back to API Reference # Workflow Types Source: https://docs.runflow.ai/api-reference/types/workflow-types TypeScript types for the Workflow system ## Core Types ### FlowContext The context object available in every step handler: ```typescript theme={null} interface FlowContext> { workflowId: string; executionId: string; input: TInput; // Original workflow input results: TResults; // All previous step results (plain object) currentStep: string; // Current step ID metadata: { startTime: Date; currentTime: Date; stepCount: number; // Steps executed so far totalSteps: number; // Total steps in workflow }; } ``` ### WorkflowConfig ```typescript theme={null} interface WorkflowConfig { id: string; name?: string; description?: string; inputSchema: z.ZodSchema; outputSchema: z.ZodSchema; steps: WorkflowStep[]; options?: WorkflowOptions; outputTransform?: (stepResults: Record, input: any) => any; } ``` ### WorkflowStep ```typescript theme={null} interface WorkflowStep { id: string; name?: string; description?: string; type: 'agent' | 'function' | 'connector' | 'condition' | 'parallel' | 'switch' | 'foreach'; config: StepConfig; inputTransform?: (previousOutput: any, workflowInput: any) => any; outputTransform?: (stepOutput: any) => any; condition?: (context: FlowContext) => boolean; retryConfig?: RetryConfig; } ``` ### AgentStepResult Returned by `.agent()` steps: ```typescript theme={null} interface AgentStepResult { text: string; metadata: { agent: string; // Agent name model: string; // Model used stepId: string; // Step ID }; } ``` ## Builder Option Types ### StepOpts Options for `.step()`: ```typescript theme={null} interface StepOpts { outputSchema?: z.ZodSchema; // Runtime output validation retryConfig?: RetryConfig; condition?: (ctx: FlowContext) => boolean; inputTransform?: (previousOutput: any, workflowInput: any) => any; outputTransform?: (stepOutput: any) => any; } ``` ### BranchOpts Options for `.branch()`: ```typescript theme={null} interface BranchOpts { condition: (ctx: FlowContext) => boolean; onTrue: StepHandler | WorkflowStep[]; onFalse?: StepHandler | WorkflowStep[]; unwrap?: boolean; // default: true } ``` ### SwitchOpts Options for `.switch()`: ```typescript theme={null} interface SwitchOpts { on: (ctx: FlowContext) => string; cases: Record; default?: StepHandler | WorkflowStep[]; unwrap?: boolean; // default: true } ``` ### ForeachOpts Options for `.foreach()`: ```typescript theme={null} interface ForeachOpts { handler: (item: any, ctx: FlowContext) => Promise; concurrency?: number; // default: 1 (sequential) } ``` ### AgentOpts Options for `.agent()`: ```typescript theme={null} interface AgentOpts { prompt?: string; promptTemplate?: string; when?: (ctx: FlowContext) => boolean; retry?: RetryConfig; } ``` ### ParallelOpts Options for `.parallel()`: ```typescript theme={null} interface ParallelOpts { waitForAll?: boolean; // default: true maxConcurrency?: number; } ``` ## Configuration Types ### RetryConfig ```typescript theme={null} interface RetryConfig { maxAttempts: number; backoff: 'fixed' | 'exponential' | 'linear'; delay: number; // Base delay in ms retryableErrors?: string[]; // Only retry matching errors } ``` ### WorkflowOptions ```typescript theme={null} interface WorkflowOptions { timeout?: number; maxRetries?: number; onError?: 'stop' | 'continue' | 'retry'; persistState?: boolean; } ``` ## Graph Types ### WorkflowGraph Returned by `workflow.toGraph()`: ```typescript theme={null} interface WorkflowGraph { id: string; name: string; nodes: GraphNode[]; edges: GraphEdge[]; } interface GraphNode { id: string; type: 'step' | 'agent' | 'condition' | 'switch' | 'parallel' | 'foreach' | 'connector'; label: string; metadata?: { description?: string; inputSchema?: object; outputSchema?: object; }; } interface GraphEdge { source: string; target: string; label?: string; // e.g. 'true', 'false', 'billing', 'default' } ``` ## Event Types ### WorkflowEvents Events emitted by `Workflow` during execution: ```typescript theme={null} interface WorkflowEvents { 'workflow:start': { workflowId: string; executionId: string; input: any }; 'workflow:complete': { executionId: string; output: any; durationMs: number }; 'workflow:error': { executionId: string; error: string; durationMs: number }; 'step:start': { stepId: string; stepType: string; input: any }; 'step:complete': { stepId: string; output: any; durationMs: number }; 'step:error': { stepId: string; error: string }; 'step:skip': { stepId: string; reason: string }; } ``` ## Handler Types ### StepHandler ```typescript theme={null} type StepHandler = ( input: TIn, ctx: FlowContext, ) => Promise; ``` ## Legacy Types (Deprecated) ### WorkflowContext ```typescript theme={null} /** @deprecated Use FlowContext instead */ interface WorkflowContext { workflowId: string; executionId: string; input: any; stepResults: Map; // Use ctx.results instead currentStep: string; metadata: { startTime: Date; currentTime: Date; stepCount: number; totalSteps: number }; runflowAPI: RunflowAPIClient; // Internal — use connector() helper } ``` ## Next Steps Observability type definitions Learn workflow concepts # Workflows Source: https://docs.runflow.ai/api-reference/workflows Workflow exports and API reference ## Exports ```typescript theme={null} import { // V2 API (recommended) flow, FlowBuilder, // Workflow class Workflow, // Step helpers createStep, createAgentStep, createFunctionStep, createConnectorStep, // Legacy (deprecated) createWorkflow, WorkflowBuilder, } from '@runflow-ai/sdk'; ``` ## `flow(config)` Creates a new `FlowBuilder` instance. This is the recommended entry point for creating workflows. ```typescript theme={null} const builder = flow({ id: 'my-workflow', name: 'My Workflow', // optional description: 'Description', // optional inputSchema: z.object({ text: z.string() }), outputSchema: z.any(), }); ``` **Parameters:** * `config.id` -- Unique workflow identifier * `config.name` -- Display name (defaults to `id`) * `config.inputSchema` -- Zod schema for input validation * `config.outputSchema` -- Zod schema for output validation **Returns:** `FlowBuilder` ## `FlowBuilder` Methods ### `.step(id, handler | opts)` Add a function step. ```typescript theme={null} // Simple handler .step('name', async (input, ctx) => ({ result: input.value * 2 })) // With options .step('name', { handler: async (input, ctx) => ({ result: input.value * 2 }), outputSchema: z.object({ result: z.number() }), // runtime validation when: (ctx) => ctx.results.previous.flag === true, // conditional retry: { maxAttempts: 3, backoff: 'exponential', delay: 1000 }, }) ``` ### `.agent(id, agent, opts?)` Add an agent step. Output is always `AgentStepResult`. ```typescript theme={null} .agent('analyze', myAgent, { prompt: 'Direct prompt text', // or promptTemplate: 'Analyze: {{input.text}}', when: (ctx) => ctx.input.needsAnalysis, retry: { maxAttempts: 2, backoff: 'fixed', delay: 500 }, }) ``` ### `.branch(id, opts)` Binary routing (if/else). ```typescript theme={null} .branch('route', { condition: (ctx) => ctx.results.classify.urgent, onTrue: async (input, ctx) => ({ path: 'urgent' }), onFalse: async (input, ctx) => ({ path: 'normal' }), unwrap: true, // default: true }) ``` `onTrue` and `onFalse` accept either a handler function or a `WorkflowStep[]` array. ### `.switch(id, opts)` Multi-way routing. ```typescript theme={null} .switch('route', { on: (ctx) => ctx.results.classify.category, cases: { billing: async (input) => ({ dept: 'billing' }), sales: async (input) => ({ dept: 'sales' }), }, default: async (input) => ({ dept: 'general' }), unwrap: true, // default: true }) ``` ### `.parallel(id, steps, opts?)` Concurrent execution. ```typescript theme={null} .parallel('enrich', [ createFunctionStep('a', async () => ({ a: true })), createFunctionStep('b', async () => ({ b: true })), ], { waitForAll: true }) ``` ### `.foreach(id, opts)` Array iteration. ```typescript theme={null} .foreach('process', { handler: async (item, ctx) => ({ processed: item }), concurrency: 5, }) ``` ### `.map(transform)` Data transformation between steps. ```typescript theme={null} .map((output) => output.items) ``` ### `.connector(id, connector, resource, action, parameters)` Connector call. ```typescript theme={null} .connector('create', 'hubspot', 'contacts', 'create', { email: '{{input.email}}', }) ``` ### `.output(transform)` Final output builder. ```typescript theme={null} .output((results, input) => ({ id: input.id, response: results.process.text, })) ``` ### `.build()` Create the `Workflow` instance. ```typescript theme={null} const workflow = builder.build(); const result = await workflow.execute(input); ``` ## `Workflow` Instance ### `.execute(input)` Execute the workflow with validated input. ```typescript theme={null} const result = await workflow.execute({ text: 'hello' }); ``` ### `.toGraph()` Get the workflow structure as a serializable DAG. ```typescript theme={null} const graph = workflow.toGraph(); // { id, name, nodes: GraphNode[], edges: GraphEdge[] } ``` ### `.on(event, handler)` Listen to execution events. See [Events](/core-concepts/workflows#real-time-events). ### `.id`, `.name`, `.steps` Read-only getters. ## Step Helpers ```typescript theme={null} // Create an agent step for use in .branch(), .switch(), .parallel() const step = createAgentStep('id', agent, { prompt: '...', promptTemplate: '...', }); // Create a function step const step = createFunctionStep('id', async (input, ctx) => { return { processed: true }; }); // Create a connector step const step = createConnectorStep('id', 'hubspot', 'contacts', 'create', { email: '{{input.email}}', }); ``` ## Next Steps TypeScript type definitions Learn workflow concepts # Best Practices Source: https://docs.runflow.ai/best-practices Practical tips for building effective Runflow agents ## Writing Good Instructions The `instructions` field is the most important part of your agent. A well-written prompt is the difference between an agent that works and one that frustrates users. ### Structure with Sections Break your instructions into clear sections so the LLM knows exactly how to behave: ```typescript Good theme={null} const agent = new Agent({ instructions: `You are a customer support agent for ACME Corp. ## Behavior - Always be professional and empathetic - Respond in the customer's language - If you don't know something, say so honestly ## Tools - Use search-orders when customers ask about orders or deliveries - Use create-ticket for issues that need human follow-up - Never create a ticket without asking the customer first ## Response Format - Be concise (2-3 paragraphs max) - Use bullet points for step-by-step instructions - Always confirm actions you've taken`, model: openai('gpt-4o'), }); ``` ```typescript Bad theme={null} const agent = new Agent({ instructions: 'You are a helpful assistant that helps customers.', model: openai('gpt-4o'), }); ``` ### Be Specific About Tool Usage Don't just list tools — tell the agent **when** and **how** to use them: ```typescript theme={null} instructions: `... ## Tools - Use get-weather ONLY when the user explicitly asks about weather or temperature - Use create-ticket when the issue cannot be resolved in this conversation - Always ask the customer to confirm before creating a ticket - Set priority based on urgency: 'high' if customer is blocked, 'medium' for inconveniences, 'low' for feature requests - Use search-orders when the customer mentions an order number or asks about delivery status - If no order is found, ask the customer to double-check the order number` ``` ### Set Boundaries Tell the agent what it should NOT do: ```typescript theme={null} instructions: `... ## Rules - Never share internal system information or error codes - Never promise refunds — escalate to a human agent - Do not answer questions outside of customer support - If a customer is upset, acknowledge their frustration before solving the problem` ``` ## Choosing the Right Approach | I want to... | Use | | ------------------------------------------- | ----------------------------------------------- | | Call an external API with custom logic | [Tool](/core-concepts/tools) | | Integrate with HubSpot, Slack, Twilio, etc. | [Connector](/core-concepts/connectors) | | Orchestrate multiple steps with conditions | [Workflow](/core-concepts/workflows) | | Make a simple LLM call without an agent | [LLM Standalone](/core-concepts/llm-standalone) | | Search in documents for context | [RAG](/core-concepts/knowledge-rag) | ### Tool vs Connector * **Tool**: You write the logic. Use when you need custom business logic, database queries, or APIs that aren't in the connector catalog. * **Connector**: Pre-built integration. Use for supported platforms (HubSpot, Slack, Twilio) — no code needed for the API call itself. ```typescript theme={null} // Tool: Custom logic, you control everything const searchOrdersTool = createTool({ id: 'search-orders', execute: async (params) => { const orders = await db.query('SELECT * FROM orders WHERE customer_id = ?', [params.customerId]); return { orders }; }, }); // Connector: Pre-built, just configure const createContactTool = createConnectorTool('hubspot', 'create_contact'); ``` ### Tool vs Workflow * **Tool**: A single action the agent can call during a conversation. * **Workflow**: A multi-step pipeline that runs independently, with conditions, retries, and different step types. Use a tool when the agent needs to do something **during** a conversation. Use a workflow when you need to orchestrate a **process** with multiple steps. ## Identify Patterns Always call `identify()` **before** `agent.process()`. It connects memory, traces, and metrics to the user. ```typescript theme={null} // WhatsApp / Phone-based identify('+5511999999999'); // Email-based identify('user@example.com'); // Multi-conversation (same user, different conversations) identify({ type: 'session', value: `${userEmail}:${conversationId}`, }); // Custom entity (order, ticket, document) identify({ type: 'order', value: 'ORDER-456', userId: 'customer_789', }); ``` Without `identify()`, memory won't persist correctly between sessions and your traces won't be linked to specific users in the dashboard. ## Tool Patterns ### One File Per Tool Keep tools in separate files. This makes them easier to find, test, and reuse: ``` tools/ ├── index.ts # Re-exports everything ├── create-ticket.ts # One tool per file ├── search-orders.ts └── send-notification.ts ``` ### Return Structured Data Always return objects with clear fields. Avoid returning raw strings — the LLM interprets structured data better: ```typescript Good theme={null} execute: async (params) => { const order = await findOrder(params.orderId); if (!order) { return { found: false, orderId: params.orderId }; } return { found: true, orderId: order.id, status: order.status, estimatedDelivery: order.deliveryDate, items: order.items.length, }; } ``` ```typescript Bad theme={null} execute: async (params) => { const order = await findOrder(params.orderId); return order ? JSON.stringify(order) : 'Not found'; } ``` ### Handle Errors Gracefully Don't let tools throw exceptions. Return error information so the LLM can inform the user: ```typescript theme={null} execute: async (params) => { try { const result = await externalApi.call(params); return { success: true, data: result }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown error', }; } } ``` ## Memory Tips ### Choose `maxTurns` Based on Use Case | Use Case | Recommended `maxTurns` | | ------------------------- | ---------------------- | | Quick Q\&A (FAQ bot) | 5-10 | | Customer support | 15-20 | | Onboarding flow | 30-50 | | Long-running conversation | 50-100 | ### Use Summarization for Long Conversations When conversations exceed `maxTurns`, older messages are dropped. Use `summarizeAfter` to preserve context: ```typescript theme={null} memory: { maxTurns: 20, summarizeAfter: 15, // Summarize when reaching 15 turns summarizePrompt: 'Summarize preserving: user name, key issues, actions taken, pending items', } ``` ## Tracking Business Metrics Use `track()` to emit events that power dashboards in the Runflow portal. Track what matters for your business: ```typescript theme={null} // Customer support metrics track('ticket_created', { priority: 'high', category: 'billing' }); track('issue_resolved', { resolution_time: 45, first_contact: true }); // Sales metrics track('lead_qualified', { score: 8, source: 'website' }); track('demo_scheduled', { company: 'TechCorp' }); // Operational metrics track('order_lookup', { found: true, orderId: 'ORD-123' }); track('knowledge_search', { query: 'refund policy', results: 3 }); ``` Use `snake_case` for event names and keep properties flat (no nested objects). This works best with the dashboard aggregations (count, sum, avg, rate). ## Input Validation Validate the input in `main()` before processing. This prevents cryptic errors: ```typescript theme={null} export async function main(input: any) { // Validate required fields if (!input?.message || typeof input.message !== 'string') { return { error: 'message is required and must be a string' }; } if (input.message.trim().length === 0) { return { error: 'message cannot be empty' }; } identify(input.email || input.phone || 'anonymous'); try { const result = await agent.process({ message: input.message.trim(), sessionId: input.sessionId, }); return { message: result.message }; } catch (error) { console.error('[agent] Error:', error); return { error: 'An error occurred while processing your message', }; } } ``` ## Next Steps Deep dive into Agents, Memory, Tools See production-ready examples Tracing and business metrics User identification patterns # Agents Management Source: https://docs.runflow.ai/cli/agents Manage AI agents with the CLI - deploy, clone, duplicate, and delete The `rf agents` command (or `rf agent` singular) provides complete agent management capabilities with an **interactive menu** and support for automation via non-interactive flags. ## Commands Overview | Command | Description | | --------------------- | --------------------------------- | | `rf agents list` | Interactive menu to manage agents | | `rf agents get` | Show current agent details | | `rf agents clone` | Clone agent repository locally | | `rf agents pull` | Pull latest changes from server | | `rf agents deploy` | Deploy local changes to server | | `rf agents duplicate` | Duplicate agent on server | | `rf agents delete` | Delete agent | ## Interactive Menu ### List Agents List all available agents with an interactive menu: ```bash theme={null} rf agents list ``` This opens an **interactive menu** where you can: * 📋 **Browse** all your agents * 🔽 **Clone** repository to local machine * 🚀 **Deploy** changes to server * 📋 **Duplicate** agent on server * 🗑️ **Delete** agent * 👁️ **View** agent details The interactive menu is the fastest way to manage agents - no need to remember multiple commands! ## Main Commands ### Get Agent Details Show details of the current/selected agent: ```bash theme={null} rf agents get ``` **Output:** ``` Agent: support-bot ID: agent_abc123 Tenant: ACME Corp (tenant_xyz) Created: 2024-01-15 Status: Active Repository: https://github.com/runflow-agents/support-bot ``` ### Clone Agent Repository Download an agent repository to your local machine: ```bash theme={null} rf agents clone ``` Or use the interactive menu (`rf agents list` → select agent → Clone repository). This creates a local folder with the agent's code, allowing you to: * Make changes locally * Test changes with `rf test` * Deploy updates with `rf agents deploy` **What gets cloned:** ``` agent-name/ ├── .runflow/ │ └── rf.json # Agent configuration ├── src/ │ └── index.ts # Agent code ├── package.json └── README.md ``` ### Deploy Changes Deploy your local changes to the server: ```bash theme={null} cd my-agent/ rf agents deploy ``` Make sure you're in the agent's directory before deploying. The CLI detects the agent from `.runflow/rf.json`. **Deployment Process:** 1. Validates `.runflow/rf.json` exists 2. Commits local changes to git 3. Pushes to remote repository 4. Server automatically rebuilds agent 5. Agent is live with new changes ### Pull Latest Changes Pull the latest changes from the server (overwrites local changes): ```bash theme={null} cd my-agent/ rf agents pull ``` This command will overwrite your local changes. Make sure to commit or backup your work before pulling. **When to use:** * After changes made via dashboard * Sync with team member changes * Reset to server state ### Duplicate Agent Create a copy of an agent on the server: ```bash theme={null} rf agents duplicate ``` Or use the interactive menu (`rf agents list` → select agent → Duplicate). **Use cases:** * Create dev/staging versions * Fork agent for different client * Experiment without affecting original ### Delete Agent Delete an agent from the server: ```bash theme={null} # With confirmation prompt rf agents delete # Skip confirmation (for scripts/automation) rf agents delete --yes rf agents delete -y ``` Or use the interactive menu (`rf agents list` → select agent → Delete). This action is permanent and cannot be undone! The agent and its repository will be deleted. ## Non-Interactive Mode All commands support `--yes` or `-y` flag to skip confirmations, perfect for automation: ```bash theme={null} # Delete without confirmation rf agents delete --yes # Use in scripts #!/bin/bash rf agents delete --yes echo "Agent deleted" ``` ## Common Workflows ### Development Workflow ```bash theme={null} # 1. Create new agent rf create --name my-agent --template starter --yes # 2. Navigate to agent folder cd my-agent/ # 3. Make your changes # ... edit src/index.ts ... # 4. Test locally rf test # 5. Deploy changes rf agents deploy # 6. Pull latest changes when needed (if changed via dashboard) rf agents pull ``` ### Existing Agent Workflow ```bash theme={null} # 1. List and select agent (interactive menu) rf agents list # 2. Clone repository (select "Clone repository" from menu) # This creates a folder with the agent name # 3. Navigate to agent folder cd agent-name/ # 4. Make your changes # ... edit files ... # 5. Test locally rf test # 6. Deploy changes rf agents deploy ``` ### Staging-to-Production Workflow Runflow has two environments per tenant: **staging** and **production**. `rf agents deploy` always deploys to staging. When staging is validated, promote it to production. ```bash theme={null} # 1. Make your changes and test locally (runs in staging mode) cd my-agent/ rf test # 2. Deploy to staging rf agents deploy # 3. Validate on the staging endpoint # ... run real traffic through the staging endpoint URL ... # 4. Promote staging to production when ready rf agents promote ``` For the full environment model — which entities are per-environment, how to publish prompts and connectors, and how the dashboard Releases screen works — see [Environments](/core-concepts/environments). ### Agent Duplication & Experimentation ```bash theme={null} # Duplicate agent for testing rf agents list # → Select agent → Duplicate # → Enter new name: "my-agent-experimental" # Clone and test rf agents clone # Select experimental version cd my-agent-experimental/ # ... make experimental changes ... rf test rf agents deploy # If successful, apply to original cd ../my-agent/ # ... apply changes ... rf agents deploy # Delete experimental version rf agents delete --yes ``` ## Project Configuration Each agent folder contains `.runflow/rf.json`: ```json theme={null} { "agentId": "agent_abc123", "agentName": "my-agent", "tenantId": "tenant_xyz" } ``` Don't delete or modify `.runflow/rf.json` - it's required for deployment and local testing! ## Using with AI Tools Non-interactive mode works seamlessly with AI coding assistants: ```bash theme={null} # Cursor / Copilot can execute these rf agents delete --yes rf agents deploy rf agents pull ``` ## Automation Scripts ### Bulk Deployment ```bash theme={null} #!/bin/bash agents=("support-bot" "sales-assistant" "feedback-analyzer") for agent in "${agents[@]}"; do cd "$agent" rf test if [ $? -eq 0 ]; then rf agents deploy else echo "Tests failed for $agent" fi cd .. done ``` ### CI/CD Integration ```bash theme={null} # .github/workflows/deploy.yml name: Deploy Agent on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Install CLI run: npm i -g @runflow-ai/cli - name: Login run: rf login --api-key ${{ secrets.RUNFLOW_API_KEY }} - name: Deploy run: rf agents deploy ``` ## Troubleshooting ### Not in Agent Directory ```bash theme={null} rf agents deploy # Error: No agent configuration found (.runflow/rf.json) ``` **Solution:** Navigate to agent directory: ```bash theme={null} cd my-agent/ rf agents deploy ``` ### Merge Conflicts on Pull ```bash theme={null} rf agents pull # Error: Local changes conflict with server ``` **Solution:** ```bash theme={null} # Commit local changes first git add . git commit -m "Local changes" # Then pull rf agents pull # Or discard local changes git reset --hard HEAD rf agents pull ``` ### Deployment Failed ```bash theme={null} rf agents deploy # Error: Deployment failed ``` **Solution:** * Check for syntax errors in your code * Verify all dependencies are in `package.json` * Check server logs via dashboard * Ensure valid `.runflow/rf.json` ## Aliases You can use either `rf agents` (plural) or `rf agent` (singular): ```bash theme={null} rf agent list # Same as rf agents list rf agent deploy # Same as rf agents deploy rf agent clone # Same as rf agents clone rf agent delete -y # Same as rf agents delete -y ``` ## Next Steps Create new agent from template Test agents locally with web interface Add knowledge base for RAG Manage prompt templates # Create Agent Source: https://docs.runflow.ai/cli/create Create new AI agents from templates with rf create The `rf create` command provides an interactive way to create new agents from pre-built templates. It handles everything: creating the agent on the server, initializing the repository, cloning locally, and installing dependencies. ## Basic Usage ```bash theme={null} # Interactive mode (recommended) rf create ``` This will guide you through: 1. Entering the agent name 2. Choosing a template 3. Creating the agent on the server 4. Cloning the repository locally 5. Installing dependencies ## Non-Interactive Mode Perfect for scripts, CI/CD pipelines, and AI tools (Cursor, Copilot): ```bash theme={null} # Minimal command rf create --name my-agent --template starter # With auto-install (skip dependency prompt) rf create --name my-agent --template rag-agent --yes # Short form rf create -n my-agent -t starter -y ``` ## Options | Option | Alias | Description | Example | | ----------------- | ----- | ------------------------------------------- | -------------------- | | `--name ` | `-n` | Agent name (non-interactive) | `--name support-bot` | | `--template ` | `-t` | Template ID or name (non-interactive) | `--template starter` | | `--yes` | `-y` | Auto-install dependencies without prompting | `--yes` | ## Available Templates ### starter **Minimal setup, perfect for beginners** A simple agent with basic configuration. Best for: * Learning RunFlow basics * Quick prototyping * Custom implementations from scratch ```bash theme={null} rf create --name my-first-agent --template starter --yes ``` ### rag-agent **Agent with knowledge base integration** Pre-configured with RAG (Retrieval Augmented Generation) capabilities. Best for: * Customer support bots * Documentation assistants * Knowledge-based Q\&A systems ```bash theme={null} rf create --name support-bot --template rag-agent --yes ``` ### webhook-handler **Process webhooks and integrations** Specialized template for handling external webhooks. Best for: * Integration workflows * Event-driven automation * Third-party service connections ```bash theme={null} rf create --name webhook-processor --template webhook-handler --yes ``` ## What Gets Created? When you run `rf create`, the following happens: 1. **Server-side Agent Creation** * Agent is created in your RunFlow account * Git repository is initialized * Template files are added 2. **Local Repository Clone** * Repository is cloned to `./agent-name/` * All template files are downloaded 3. **Project Configuration** * `.runflow/rf.json` is created with agent metadata * Contains: `agentId`, `agentName`, `tenantId` 4. **Dependencies Installation** (if confirmed) * `npm install` is run automatically * Or skipped if `--yes` flag is used ## Complete Workflow Example ```bash theme={null} # 1. Create agent from template rf create # → Enter name: "support-bot" # → Select template: "rag-agent" # → Install dependencies: Yes # 2. Navigate to agent folder cd support-bot/ # 3. Review the structure ls -la # .runflow/rf.json - Project configuration # src/ - Agent source code # package.json - Dependencies # README.md - Template documentation # 4. Set up knowledge base rf kb create support-docs rf kb upload support-docs ./docs/faq.pdf # 5. Test locally rf test # 6. Make your changes # ... edit src/index.ts ... # 7. Deploy to production rf agents deploy ``` ## Using with AI Tools The non-interactive mode is designed for seamless integration with AI coding assistants: ```bash theme={null} # Cursor / Copilot can execute this directly rf create --name customer-support --template rag-agent --yes ``` **Why it's AI-friendly:** * No user interaction required * All parameters via flags * Automatic dependency installation with `--yes` * Predictable output * Exit codes for success/failure ## Automation & Scripts Use in bash scripts or CI/CD pipelines: ```bash theme={null} #!/bin/bash # Create multiple agents programmatically agents=("support-bot" "sales-assistant" "feedback-analyzer") for agent in "${agents[@]}"; do rf create --name "$agent" --template rag-agent --yes cd "$agent" rf kb create "$agent-knowledge" cd .. done ``` ## Project Structure After creation, your agent folder will contain: ``` my-agent/ ├── .runflow/ │ └── rf.json # Agent configuration ├── src/ │ └── index.ts # Main agent code ├── package.json # Dependencies ├── tsconfig.json # TypeScript config └── README.md # Template documentation ``` ### .runflow/rf.json This file contains essential metadata: ```json theme={null} { "agentId": "agent_uuid", "agentName": "my-agent", "tenantId": "tenant_123" } ``` Don't delete `.runflow/rf.json` - it's required for `rf test` and `rf agents deploy` to work! ## Common Options Combination ```bash theme={null} # Quick setup for development rf create -n dev-agent -t starter -y # Production RAG agent rf create -n prod-support -t rag-agent -y # Webhook handler without dependencies (install later) rf create -n webhook-handler -t webhook-handler # → Skip dependency installation when prompted ``` ## Troubleshooting ### Template Not Found ```bash theme={null} rf create --name test --template invalid-template # Error: Template 'invalid-template' not found ``` **Solution:** Use one of the available templates: `starter`, `rag-agent`, `webhook-handler` ### Agent Name Already Exists ```bash theme={null} rf create --name existing-agent --template starter # Error: Agent 'existing-agent' already exists ``` **Solution:** Choose a different name or delete the existing agent first with `rf agents delete` ### Dependencies Installation Failed If dependency installation fails: ```bash theme={null} # Navigate to agent folder cd my-agent/ # Manually install dependencies npm install # Or use yarn/pnpm yarn install pnpm install ``` ## Next Steps Test your newly created agent Add knowledge base for RAG Manage prompt templates Deploy to production # CLI Installation Source: https://docs.runflow.ai/cli/installation Install Runflow CLI globally ## Installation Install the CLI globally using npm: ```bash theme={null} npm i -g @runflow-ai/cli ``` ## Verify Installation After installation, verify that the CLI is working: ```bash theme={null} rf --version ``` Or check the help: ```bash theme={null} rf --help ``` ## Requirements * **Node.js**: >= 22.0.0 * **npm** or **yarn** or **pnpm** ## Next Steps Sign in with rf login Manage your agents # CLI Overview Source: https://docs.runflow.ai/cli/introduction Command line interface to manage AI agents, knowledge bases, and prompts via API Portal Install the CLI tool Create agents from templates Authenticate and manage profiles Manage your AI agents Manage vector stores for RAG Manage prompt templates Multi-tenant profile management Test agents locally with web interface ## What is Runflow CLI? RunFlow CLI is a powerful command line interface to manage AI agents via **API Portal**. Create agents from templates, manage knowledge bases, handle prompts, and switch between multiple tenants — all from the terminal. ## Key Features * 🚀 **Create agents from templates** with `rf create` * 🧠 **Manage knowledge bases** (vector stores) with `rf kb` * 📝 **Full prompts management** with CRUD and editor integration * 👥 **Multi-tenant profiles** - switch between tenants and environments easily * 🧪 **Local testing** with web interface and live reload * 🤖 **AI-friendly** - non-interactive flags for automation ## Quick Start ```bash theme={null} # 1. Log in (opens your browser) rf login # 2. Create a new agent from template rf create # 3. Navigate to the created folder cd my-agent/ # 4. Test locally with web interface rf test # 5. Deploy to production rf agents deploy ``` ## Main Commands | Command | Description | | ------------ | ------------------------------------------------- | | `rf create` | Create new agents from templates ⭐ | | `rf login` | Log in via browser (or API key for automation) | | `rf switch` | Switch between tenants/profiles | | `rf agents` | Manage AI agents (deploy, clone, duplicate) | | `rf kb` | Manage knowledge bases for RAG | | `rf prompts` | Manage prompt templates (CRUD) | | `rf test` | Start local development server with web interface | ## Interactive & Non-Interactive Modes All commands support both **interactive** and **non-interactive** modes: ### Interactive Mode (Default) Perfect for developers working manually: ```bash theme={null} rf create # Walks you through agent creation rf login # Opens browser login (pick your tenant) rf prompts create # Opens editor for content ``` ### Non-Interactive Mode Perfect for scripts, CI/CD, and AI tools (Cursor, Copilot): ```bash theme={null} rf create --name my-agent --template starter --yes rf login --api-key sk-xxx --profile prod rf prompts create support --content "You are a helpful assistant" rf kb upload docs ./files --yes rf agents delete --yes ``` Use `--yes` or `-y` to skip confirmations in scripts and automation! ## Multi-Tenant Support If your account has access to multiple tenants, switch between them without logging in again: ```bash theme={null} # Log in once (browser) rf login # Switch between your tenants rf switch acme-corp rf agents list rf switch tech-startup rf agents list # List all profiles rf profiles ``` ## Local Development Test your agents locally before deploying: ```bash theme={null} cd my-agent/ rf test ``` **Features:** * Zero config - auto-detects from `.runflow/rf.json` * Web portal with real-time monitoring * Live reload on file changes * Traces saved to `.runflow/traces.json` ## Complete Workflow Example ```bash theme={null} # 1. Login and create agent rf login rf create # → Select "RAG Agent" template # → Name: "support-bot" # 2. Navigate and set up knowledge base cd support-bot/ rf kb create support-docs rf kb upload support-docs ./docs --yes # 3. Create prompt template rf prompts create system --content "You are a support assistant" # 4. Test locally rf test # 5. Deploy to production rf agents deploy ``` ## Next Steps Install the CLI Create your first agent Authenticate # Knowledge Base Management Source: https://docs.runflow.ai/cli/kb Manage vector stores for RAG (Retrieval Augmented Generation) with rf kb The `rf kb` command provides complete knowledge base (vector store) management for RAG applications. Upload documents, perform semantic search, check processing status, and manage your knowledge bases. ## Commands Overview | Command | Description | | -------------------------------- | -------------------------------------------- | | `rf kb list` | List all knowledge bases | | `rf kb create ` | Create new knowledge base | | `rf kb upload ` | Upload documents (async, with live progress) | | `rf kb jobs ` | List ingestion jobs and their progress | | `rf kb status ` | Check processing status | | `rf kb docs ` | List documents in KB | | `rf kb remove-doc ` | Remove specific document | | `rf kb search ` | Semantic search | | `rf kb delete ` | Delete knowledge base | ## Listing Knowledge Bases ```bash theme={null} # List all knowledge bases rf kb list # Output: # Knowledge Bases: # • support-docs (100 documents) # • product-manual (45 documents) # • faq-knowledge (12 documents) ``` ## Creating Knowledge Base ### Interactive Mode ```bash theme={null} rf kb create support-docs # → Select embedding configuration: # • OpenAI Small (text-embedding-3-small) # • OpenAI Large (text-embedding-3-large) # • Custom... # ✓ Knowledge base 'support-docs' created ``` ### Non-Interactive Mode Perfect for scripts and automation: ```bash theme={null} # Using embedding name rf kb create support-docs --embedding "OpenAI Small" # Using short form rf kb create support-docs -e openai-small # Using embedding config ID rf kb create support-docs --embedding embedding-cfg-123 ``` ### Options | Option | Alias | Description | | ---------------------- | ----- | --------------------------- | | `--embedding ` | `-e` | Embedding config ID or name | | `--yes` | `-y` | Skip confirmations | ## Uploading Documents ### Upload Single File ```bash theme={null} # Upload one file rf kb upload support-docs ./manual.pdf # ⠋ Processing manual.pdf (450/1200 chunks) # ✓ manual.pdf: 1200 chunks indexed # Supported formats rf kb upload support-docs ./faq.txt rf kb upload support-docs ./guide.md rf kb upload support-docs ./doc.docx rf kb upload support-docs ./catalog.csv ``` Since CLI `0.3.22`, uploads use the platform's **async ingestion pipeline**: the file is accepted immediately and embedded in the background with batched embeddings and checkpoint resume; the CLI shows live chunk progress until the job completes. Large files (50k+ row CSV catalogs) no longer time out. Against an older self-hosted API without the async endpoint, the CLI falls back to the legacy synchronous upload automatically. ### Upload Directory ```bash theme={null} # Upload all files in directory (with confirmation) rf kb upload support-docs ./docs # ? Upload 15 files from ./docs? (y/N) # Skip confirmation rf kb upload support-docs ./docs --yes # ✓ Uploaded 15 files ``` ### Supported File Types * **PDF** (`.pdf`) * **Text** (`.txt`) * **Markdown** (`.md`) * **Word Documents** (`.docx`) * **CSV** (`.csv`) — ingested one document per row (great for product catalogs) * **JSON** (`.json`) Files are automatically parsed, chunked, and embedded for semantic search. ## Ingestion Jobs Every upload creates an ingestion job you can inspect at any time: ```bash theme={null} rf kb jobs support-docs # Output: # 📥 Ingestion jobs in "support-docs" # # completed manual.pdf 1200/1200 chunks 7/1/2026, 3:12:04 PM # processing catalog.csv 31488/53068 chunks 7/1/2026, 3:20:11 PM # failed broken-export.csv - 7/1/2026, 2:55:40 PM # Unsupported file type: application/octet-stream... ``` Jobs survive worker restarts: ingestion resumes from the last checkpoint instead of starting over. ## Checking Processing Status ```bash theme={null} # Check if documents are processed rf kb status support-docs # Output: # Knowledge Base: support-docs # Status: Processing # Documents: 15 total, 12 processed, 3 pending # Embedding: OpenAI Small ``` **Statuses:** * **Processing** - Documents being embedded * **Ready** - All documents processed * **Error** - Some documents failed ## Listing Documents ```bash theme={null} # List all documents in KB rf kb docs support-docs # Output: # Documents in support-docs: # • doc_abc123 - manual.pdf (1.2 MB, 45 chunks) # • doc_def456 - faq.txt (50 KB, 12 chunks) # • doc_ghi789 - guide.md (200 KB, 28 chunks) ``` ## Removing Documents ```bash theme={null} # Remove with confirmation rf kb remove-doc support-docs doc_abc123 # ? Are you sure you want to remove document 'doc_abc123'? (y/N) # Remove without confirmation rf kb remove-doc support-docs doc_abc123 --yes # ✓ Document removed # Short form rf kb remove-doc support-docs doc_abc123 -y ``` Removing a document is permanent and cannot be undone! ## Semantic Search Test your knowledge base with semantic search: ```bash theme={null} # Search for relevant content rf kb search support-docs "how to reset password" # Output: # Search Results: # # [1] Score: 0.92 # Source: faq.txt # Content: To reset your password, go to Settings > Security... # # [2] Score: 0.87 # Source: manual.pdf # Content: Password Reset Procedure: 1. Click Forgot Password... # # [3] Score: 0.81 # Source: guide.md # Content: User accounts can be recovered by... ``` **Search Features:** * Semantic similarity (not just keyword matching) * Ranked by relevance score (0-1) * Shows source document * Returns top relevant chunks ## Deleting Knowledge Base ```bash theme={null} # Delete with confirmation rf kb delete support-docs # ? Are you sure you want to delete knowledge base 'support-docs'? (y/N) # Delete without confirmation rf kb delete support-docs --yes # ✓ Knowledge base deleted # Short form rf kb delete support-docs -y ``` Deleting a knowledge base removes all documents and embeddings permanently! ## Complete Workflow Example ```bash theme={null} # 1. Create knowledge base rf kb create support-docs --embedding "OpenAI Small" # 2. Upload documents rf kb upload support-docs ./docs/manual.pdf rf kb upload support-docs ./docs/faq.md rf kb upload support-docs ./docs --yes # 3. Check processing status rf kb status support-docs # Wait until status is "Ready" # 4. Test semantic search rf kb search support-docs "how to install" rf kb search support-docs "pricing information" rf kb search support-docs "troubleshooting errors" # 5. List all documents rf kb docs support-docs # 6. Remove outdated document rf kb remove-doc support-docs doc_old123 --yes # 7. Use in your agent cd my-agent/ rf test # → Agent now has access to support-docs knowledge base ``` ## Using with RAG Agents ### Creating RAG Agent with Knowledge Base ```bash theme={null} # 1. Create agent from RAG template rf create --name support-bot --template rag-agent --yes # 2. Create and populate knowledge base rf kb create support-knowledge --embedding "OpenAI Small" rf kb upload support-knowledge ./company-docs --yes # 3. Test locally cd support-bot/ rf test # 4. Deploy rf agents deploy ``` ### Agent Configuration Your agent code will reference the knowledge base: ```typescript theme={null} import { Agent } from '@runflow-ai/core'; const agent = new Agent({ name: 'Support Bot', instructions: 'Use the knowledge base to answer questions', knowledgeBase: 'support-knowledge', // Reference your KB model: 'gpt-4', }); ``` ## Automation & Scripts ### Bulk Upload Script ```bash theme={null} #!/bin/bash # Create KB rf kb create product-docs --embedding "OpenAI Small" # Upload all PDFs find ./documents -name "*.pdf" -exec rf kb upload product-docs {} \; # Wait for processing while [ "$(rf kb status product-docs | grep 'Status:' | awk '{print $2}')" != "Ready" ]; do echo "Processing..." sleep 5 done echo "Knowledge base ready!" ``` ### Multi-KB Setup ```bash theme={null} #!/bin/bash # Setup multiple knowledge bases kb_configs=( "support-docs:./support-files" "product-manual:./manuals" "legal-docs:./legal" ) for config in "${kb_configs[@]}"; do kb_name="${config%%:*}" kb_dir="${config##*:}" rf kb create "$kb_name" --embedding "OpenAI Small" rf kb upload "$kb_name" "$kb_dir" --yes done ``` ## Best Practices ### 1. Organize Documents by Domain ```bash theme={null} # Create separate KBs for different topics rf kb create customer-support rf kb create product-documentation rf kb create legal-compliance rf kb create internal-wiki ``` ### 2. Keep Documents Updated ```bash theme={null} # Remove old versions rf kb remove-doc support-docs doc_old_manual --yes # Upload new versions rf kb upload support-docs ./manual-v2.pdf ``` ### 3. Test Before Production ```bash theme={null} # Always test search quality rf kb search my-kb "common query 1" rf kb search my-kb "common query 2" rf kb search my-kb "edge case query" # Refine documents if results are poor ``` ### 4. Choose Appropriate Embedding Model ```bash theme={null} # Small model - faster, cheaper, good for most use cases rf kb create kb-small --embedding "OpenAI Small" # Large model - more accurate, better for complex domains rf kb create kb-large --embedding "OpenAI Large" ``` ### 5. Monitor Processing Status ```bash theme={null} # After large uploads, monitor status rf kb upload docs ./large-dataset --yes rf kb status docs # Wait for processing to complete before using ``` ## Document Processing ### How It Works 1. **Upload** - File sent to server 2. **Parse** - Content extracted (text from PDF, DOCX, etc.) 3. **Chunk** - Split into manageable pieces (\~500 tokens) 4. **Embed** - Generate vector embeddings 5. **Index** - Store in vector database 6. **Ready** - Available for search ### Processing Time * Small files (\<1 MB): \~5-10 seconds * Medium files (1-10 MB): \~30-60 seconds * Large files (>10 MB): \~2-5 minutes * Directories: Depends on total size and file count ### Chunking Strategy Documents are automatically chunked with: * **Chunk size**: \~500 tokens (\~375 words) * **Overlap**: 50 tokens (for context continuity) * **Smart splitting**: Respects paragraph boundaries ## Troubleshooting ### KB Already Exists ```bash theme={null} rf kb create existing-kb # Error: Knowledge base 'existing-kb' already exists ``` **Solution:** Use a different name or delete the existing KB: ```bash theme={null} rf kb delete existing-kb --yes rf kb create existing-kb --embedding "OpenAI Small" ``` ### Upload Failed ```bash theme={null} rf kb upload docs ./file.pdf # Error: Failed to upload file ``` **Possible causes:** * File format not supported * File too large (>50 MB) * Network connection issues **Solution:** * Check file format (PDF, TXT, MD, DOCX only) * Split large files * Retry upload ### Documents Stuck in Processing ```bash theme={null} rf kb status docs # Status: Processing (stuck for 30+ minutes) ``` **Solution:** * Check document format and content * Contact support if issue persists * Remove and re-upload problematic documents ### Poor Search Results If search quality is low: 1. **Add more documents** - More context improves results 2. **Use larger embedding model** - Better semantic understanding 3. **Improve document quality** - Clear, well-structured content 4. **Test queries** - Refine search terms ```bash theme={null} # Try different embedding rf kb create kb-improved --embedding "OpenAI Large" rf kb upload kb-improved ./docs --yes rf kb search kb-improved "test query" ``` ## Advanced Usage ### Environment-Specific KBs ```bash theme={null} # Development KB with test data rf switch dev rf kb create test-kb --embedding "OpenAI Small" rf kb upload test-kb ./test-data --yes # Production KB with real data rf switch prod rf kb create prod-kb --embedding "OpenAI Large" rf kb upload prod-kb ./production-data --yes ``` ### Backup and Restore ```bash theme={null} # Document metadata can be exported via API # (CLI backup feature coming soon) # For now, keep source files backed up cp -r ./original-docs ./backup-$(date +%Y%m%d) ``` ## Next Steps Create agent with knowledge base Test KB integration locally Create RAG-specific prompts Learn more about RAG concepts # Login & Authentication Source: https://docs.runflow.ai/cli/login Sign in to RunFlow with rf login — browser login by default, API key for automation The recommended way to authenticate is simply: ```bash theme={null} rf login ``` This opens your **browser** for RunFlow login (OIDC with PKCE). No API key needed — you sign in with your regular RunFlow account, pick a tenant if you have more than one, and the CLI saves everything as a profile. ```bash theme={null} rf login # Opening browser for Runflow login... # # ✓ Login successful! # Profile: acme-corporation # User: Jane Doe # Tenant: ACME Corporation (ADMIN) ``` An API key is only needed for **non-interactive** environments (CI/CD, scripts, servers without a browser). See [API key login](#api-key-login-ci-and-automation) below. ## How It Works 1. **Browser opens** on the RunFlow login page (the CLI listens on a local callback, ports `8630`–`8640`). 2. **You sign in** with your RunFlow account (email/password or SSO). 3. **Tenant selection** — if your account has access to multiple tenants, the CLI shows a searchable list to pick the active one. 4. **Profile saved** — access token, refresh token, tenant, and API URL are stored in `~/.runflowrc`. The profile is named after the tenant unless you pass `--profile`. Tokens are **refreshed automatically** — you won't be asked to log in again until the refresh token expires or you run `rf logout`. Verify your session at any time: ```bash theme={null} rf whoami ``` ## Options | Option | Description | Example | | ------------------ | ----------------------------------------------------------------- | ------------------------------------- | | `--profile ` | Save under a custom profile name (defaults to the tenant name) | `rf login --profile staging` | | `--api ` | Target a self-hosted installation (remembered in the profile) | `rf login --api https://api.acme.com` | | `--api-key ` | Skip the browser and authenticate with an API key (CI/automation) | `rf login --api-key sk-xxx...` | ## Switching Tenants If your account has access to multiple tenants, switch between them anytime — no new login required: ```bash theme={null} rf switch # → searchable list of your tenants rf switch acme-corp # ✓ Switched to tenant: ACME Corporation ``` ## Multiple Environments Each profile remembers its own API URL and identity provider, so cloud and self-hosted installs live side by side: ```bash theme={null} # RunFlow Cloud rf login --profile cloud # Self-hosted staging rf login --profile staging --api https://api.staging.yourcompany.com ``` When you pass `--api`, the CLI discovers the installation's login provider from the server and remembers the URL in the profile, so later logins and refreshes reuse it automatically. Running RunFlow in your own environment, or switching between several environments? See [Self-Hosted & Multiple Environments](/cli/self-hosted). ## API Key Login (CI and Automation) For pipelines, scripts, and headless machines, authenticate with an API key instead of the browser: ```bash theme={null} rf login --api-key sk-1234567890abcdef # Optionally under a named profile rf login --api-key sk-xxx... --profile ci ``` ### Getting an API Key 1. Go to the [RunFlow Portal](https://app.runflow.ai) 2. Navigate to **Settings** → **API Keys** 3. Click **Create New API Key** 4. Copy the key (starts with `sk-...`) ### Example: GitHub Actions ```yaml theme={null} - name: Login run: rf login --api-key ${{ secrets.RUNFLOW_API_KEY }} - name: Deploy run: rf agents deploy ``` Never hardcode API keys in scripts or commit them to version control. Use your CI provider's secret storage. ## Signing Out `rf logout` clears the stored tokens but keeps the API URL, provider, and tenant, so signing back in is a single `rf login` with no flags: ```bash theme={null} rf logout # current profile rf logout staging # a specific profile rf logout --all # every profile ``` ## Configuration File Credentials are stored per profile in `~/.runflowrc` (YAML): ```yaml theme={null} currentProfile: acme-corporation profiles: # Browser (OIDC) login acme-corporation: token: eyJhbGciOi... refreshToken: v1.MRr... tokenExpiresAt: 2026-08-01T12:00:00.000Z email: jane@acme.com tenantId: tenant_abc123 tenantName: ACME Corporation api: https://api.runflow.ai/api/v1/runtime # API key login (CI) ci: apiKey: sk-xxx... tenantId: tenant_abc123 api: https://api.runflow.ai/api/v1/runtime ``` Credentials are stored in plain text in `~/.runflowrc`. Ensure proper file permissions: ```bash theme={null} chmod 600 ~/.runflowrc ``` **Recommendations:** * Never share your `~/.runflowrc` file * Don't commit `.runflowrc` to version control * Prefer browser login for humans; reserve API keys for automation * Use separate API keys for dev/staging/prod * Rotate keys regularly and revoke unused ones from the portal ## Troubleshooting ### Browser Doesn't Open / Login Times Out ```bash theme={null} rf login # Error: Login timed out. Please try again. ``` **Solution:** * Check that a browser is available on the machine — on headless servers, use `rf login --api-key` instead * Make sure nothing blocks `localhost` ports `8630`–`8640` (firewall, VPN) * Try again — the login link waits a limited time for the callback ### No Tenant Found ```bash theme={null} rf login # Error: Login failed: No tenant information found in your account. ``` **Solution:** Your user isn't linked to any tenant yet. Ask your workspace admin for an invite, or sign up at the [RunFlow Portal](https://app.runflow.ai) first. ### Invalid API Key ```bash theme={null} rf login --api-key sk-invalid # Error: Invalid API key ``` **Solution:** Check your API key in the portal and try again. ### Network Connection Error ```bash theme={null} rf login # Error: Cannot connect to API ``` **Solution:** * Check your internet connection * Verify the API URL is correct (`--api` for self-hosted) * Check if the API is accessible (firewall, VPN) ### Permission Denied (Config File) ```bash theme={null} rf login # Error: Permission denied: ~/.runflowrc ``` **Solution:** ```bash theme={null} # Fix file permissions chmod 600 ~/.runflowrc # Or remove and recreate rm ~/.runflowrc rf login ``` ## Next Steps Manage multiple profiles Point the CLI at your own installation Create your first agent Test agents locally # Profile Management Source: https://docs.runflow.ai/cli/profiles Manage login profiles and switch between tenants and environments RunFlow CLI supports **multi-tenant profiles**. A profile stores a login session — who you are, which tenant is active, and which API you're talking to — so you can switch between accounts, clients, and environments without re-entering credentials. A profile is created automatically when you run [`rf login`](/cli/login). Browser logins store your tokens (refreshed automatically); API-key logins store the key. ## Why Use Profiles? Profiles are perfect for: * **Agencies** managing multiple client accounts * **Developers** working across dev, staging, and production * **Teams** switching between different projects * **Consultants** handling multiple customer environments ## Commands Overview | Command | Description | | --------------------------- | ------------------------------------------------------- | | `rf login --profile ` | Log in and save the session as a named profile | | `rf switch [name]` | Switch tenant (browser login) or profile (API key) | | `rf profiles` | List all saved profiles | | `rf profiles current` | Show current active profile | | `rf profiles delete ` | Delete a profile | | `rf logout [name]` | Sign out (keeps API URL and provider for easy re-login) | ## Creating Profiles There's no separate "create profile" command — a profile is created (and activated) when you log in: ```bash theme={null} # Browser login — profile is named after the tenant you pick rf login # ✓ Login successful! # Profile: acme-corporation # Browser login with a custom profile name rf login --profile staging --api https://api.staging.yourcompany.com ``` ### Non-Interactive Profile Creation (API Key) For CI/CD and scripts, create profiles with API keys directly: ```bash theme={null} rf login --api-key sk-xxx... --profile production rf login --api-key sk-yyy... --profile development ``` ## Switching: Tenants vs. Profiles `rf switch` behaves according to how the active profile was created: * **Browser (OIDC) login** — `rf switch` moves between the **tenants of the active account**. No new login needed. * **API-key login** — `rf switch ` moves between your **saved local profiles**. To activate a different *environment* (another profile with its own API URL), log into it again — `rf login --profile ` both creates and activates that profile. See [Self-Hosted & Multiple Environments](/cli/self-hosted). ### Interactive Switch ```bash theme={null} # Searchable list (tenants for browser login, profiles for API key) rf switch # → Select active tenant: # • ACME Corporation (primary) # • Tech Startup ``` ### Direct Switch ```bash theme={null} rf switch acme-corp # ✓ Switched to tenant: ACME Corporation # Verify what's active rf profiles current # Current profile: acme-corporation (tenant: tenant_123) ``` ## Listing Profiles ```bash theme={null} # List all saved profiles rf profiles # Output: # Available profiles: # • acme-corporation (current) # • staging # • production ``` ### Show Current Profile ```bash theme={null} rf profiles current # Output: # Current profile: acme-corporation # Tenant ID: tenant_abc123 # Tenant Name: ACME Corporation # API URL: https://api.runflow.ai ``` ## Deleting Profiles ```bash theme={null} # Delete a profile (with confirmation) rf profiles delete acme-corp # ? Are you sure you want to delete profile 'acme-corp'? (y/N) # Delete without confirmation rf profiles delete acme-corp --yes # ✓ Profile 'acme-corp' deleted ``` You cannot delete the currently active profile. Switch to another profile first. To just sign out of a profile while keeping its API URL and provider, use `rf logout ` instead of deleting it — signing back in becomes a single `rf login`. ## Multi-Tenant Workflow Example ### Agency Managing Multiple Clients If your RunFlow account has access to each client's tenant, one login is enough: ```bash theme={null} rf login # → pick any tenant to start # Work with Client ACME rf switch client-acme rf agents list rf create --name acme-support-bot --template rag-agent --yes # Switch to a different client rf switch client-techco rf agents list rf kb create techco-docs ``` ### Developer: Dev, Staging, Production Separate environments (different API URLs) live in separate profiles: ```bash theme={null} # Log into each environment once rf login --profile dev --api https://api.dev.yourcompany.com rf login --profile staging --api https://api.staging.yourcompany.com rf login --profile prod --api https://api.yourcompany.com # Develop on dev (re-login activates the profile) rf login --profile dev rf create --name test-agent --template starter --yes cd test-agent/ rf test # Deploy to production rf login --profile prod rf agents deploy ``` For CI pipelines, use API-key profiles instead: ```bash theme={null} rf login --profile ci --api-key sk-ci... rf agents deploy ``` ## Configuration File Profiles are stored in `~/.runflowrc` (YAML): ```yaml theme={null} currentProfile: acme-corporation profiles: # Browser (OIDC) login acme-corporation: token: eyJhbGciOi... refreshToken: v1.MRr... tokenExpiresAt: 2026-08-01T12:00:00.000Z email: jane@acme.com tenantId: tenant_abc123 tenantName: ACME Corporation api: https://api.runflow.ai/api/v1/runtime # API key login (CI) ci: apiKey: sk-xxx... tenantId: tenant_abc123 api: https://api.runflow.ai/api/v1/runtime ``` The config file is automatically managed by the CLI. You typically don't need to edit it manually. ## Profile Aliases You can use profile names in any command that requires authentication: ```bash theme={null} # Override profile for single command rf agents list --profile production # Create agent using specific profile rf create --name test --template starter --profile development ``` ## Best Practices ### 1. Use Descriptive Names The default name (the tenant name) is usually fine. When naming manually: ```bash theme={null} # ✅ Good - clear and descriptive rf login --profile client-acme-production rf login --profile internal-dev rf login --profile staging-us-west # ❌ Avoid - unclear names rf login --profile profile1 rf login --profile test rf login --profile x ``` ### 2. Separate Environments ```bash theme={null} # Create separate profiles for each environment rf login --profile mycompany-dev --api https://api.dev.mycompany.com rf login --profile mycompany-staging --api https://api.staging.mycompany.com rf login --profile mycompany-prod --api https://api.mycompany.com ``` ### 3. Client Naming Convention For agencies managing multiple clients across environments: ```bash theme={null} rf login --profile clientname-environment rf login --profile acme-prod rf login --profile acme-dev rf login --profile techco-prod ``` ## Security Considerations Tokens and API keys are stored in plain text in `~/.runflowrc`. Ensure this file has proper permissions: ```bash theme={null} chmod 600 ~/.runflowrc ``` ### Best Practices: * Don't share your `~/.runflowrc` file * Prefer browser login for humans; reserve API keys for automation * Use environment-specific API keys * Rotate keys regularly * Delete unused profiles * Never commit `.runflowrc` to git ## Troubleshooting ### Profile Not Found ```bash theme={null} rf switch nonexistent # Error: Profile 'nonexistent' not found ``` **Solution:** List available profiles with `rf profiles` and use an existing one. Remember: with a browser login, `rf switch` expects a **tenant** name, not a profile name. ### Cannot Delete Current Profile ```bash theme={null} rf profiles delete acme-corp # Error: Cannot delete the current profile ``` **Solution:** Switch to another profile first: ```bash theme={null} rf login --profile other-profile rf profiles delete acme-corp ``` ### Expired Session If a browser-login session can no longer refresh (long inactivity, revoked access), just log in again — the profile keeps its API URL and provider: ```bash theme={null} rf login --profile staging ``` ### Lost API Key If you lose access to a profile's API key: ```bash theme={null} # Delete the old profile rf profiles delete old-profile --yes # Create new profile with new API key rf login --profile new-profile --api-key sk-new... ``` ## Next Steps Learn more about authentication Multiple environments and custom APIs Create agents with your profile Manage agents across profiles # Prompts Management Source: https://docs.runflow.ai/cli/prompts Manage prompt templates with full CRUD operations using rf prompts The `rf prompts` command provides complete CRUD (Create, Read, Update, Delete) operations for managing prompt templates. Supports both interactive mode with editor integration and non-interactive mode for automation. ## Commands Overview | Command | Description | | --------------------------------- | ------------------------------------------- | | `rf prompts list` | List all prompt templates | | `rf prompts get ` | View prompt content | | `rf prompts create ` | Create new prompt (interactive editor) | | `rf prompts update ` | Update existing prompt (interactive editor) | | `rf prompts delete ` | Delete prompt | | `rf prompts render ` | Render prompt with variables | ## Listing Prompts ```bash theme={null} # Interactive mode - shows menu rf prompts # List all prompts rf prompts list # Output: # Available prompts: # • system-prompt # • customer-support # • feedback-analyzer # • onboarding-assistant ``` ## Getting Prompt Content ```bash theme={null} # View specific prompt rf prompts get system-prompt # Output: # Prompt: system-prompt # --- # You are a helpful AI assistant. # Your goal is to help users with their questions. # Always be polite and professional. ``` ## Creating Prompts ### Interactive Mode (Default) Opens your default editor for content input: ```bash theme={null} rf prompts create support-bot # → Opens editor (vim, nano, vscode, etc.) # → Enter your prompt content # → Save and close # ✓ Prompt 'support-bot' created ``` ### Non-Interactive Mode Perfect for scripts, CI/CD, and AI tools: ```bash theme={null} # With inline content rf prompts create support-bot --content "You are a helpful support assistant" # From file rf prompts create support-bot --file ./prompts/support.txt # Multi-line content rf prompts create support-bot --content "You are a support assistant. Always be helpful and polite. Provide clear and concise answers." ``` ### Options | Option | Alias | Description | | ------------------ | ----- | -------------------------------- | | `--content ` | `-c` | Prompt content (non-interactive) | | `--file ` | `-f` | Read content from file | ## Updating Prompts ### Interactive Mode (Default) Opens editor with current content: ```bash theme={null} rf prompts update support-bot # → Opens editor with existing content # → Make your changes # → Save and close # ✓ Prompt 'support-bot' updated ``` ### Non-Interactive Mode ```bash theme={null} # Update with new content rf prompts update support-bot --content "Updated prompt content" # Update from file rf prompts update support-bot --file ./prompts/support-v2.txt ``` ## Deleting Prompts ```bash theme={null} # With confirmation rf prompts delete support-bot # ? Are you sure you want to delete prompt 'support-bot'? (y/N) # Skip confirmation rf prompts delete support-bot --yes rf prompts delete support-bot -y # Alternative alias rf prompt delete old-prompt --yes ``` ## Rendering Prompts with Variables If your prompts use template variables, you can test rendering: ```bash theme={null} # Render with variables (JSON format) rf prompts render welcome-email '{"name": "John", "company": "ACME"}' # Output: # Welcome to ACME, John! # We're excited to have you on board... ``` **Example prompt with variables:** ``` Hello {{name}}, Welcome to {{company}}! We're thrilled to have you join us. Best regards, The {{company}} Team ``` ## Prompt Templates Examples ### System Prompt ```bash theme={null} rf prompts create system --content "You are a helpful AI assistant powered by RunFlow. Your capabilities: - Answer questions accurately - Provide code examples - Explain complex concepts simply - Always be professional and helpful Guidelines: - Be concise but thorough - Ask clarifying questions when needed - Admit when you don't know something" ``` ### Customer Support ```bash theme={null} rf prompts create support --content "You are a customer support assistant for {{company}}. Your role: - Help customers resolve issues - Answer product questions - Escalate complex issues to human agents Tone: - Friendly and empathetic - Professional and clear - Patient and understanding Always: 1. Greet the customer warmly 2. Listen to their concern 3. Provide step-by-step solutions 4. Ask if they need further help" ``` ### RAG (Knowledge Base) Prompt ```bash theme={null} rf prompts create rag-assistant --content "You are a knowledge base assistant. Context from knowledge base: {{context}} User question: {{question}} Instructions: - Answer based ONLY on the provided context - If the answer isn't in the context, say 'I don't have that information' - Cite sources when possible - Be accurate and concise" ``` ### Code Review Assistant ```bash theme={null} rf prompts create code-review --file ./prompts/code-review.txt ``` **File: `prompts/code-review.txt`** ``` You are a senior software engineer reviewing code. Review the following code and provide feedback on: 1. Code quality and best practices 2. Potential bugs or issues 3. Performance optimizations 4. Security concerns 5. Readability and maintainability Be constructive and specific in your feedback. Code to review: {{code}} ``` ## Using with AI Tools Non-interactive mode is designed for AI coding assistants: ```bash theme={null} # Cursor / Copilot can execute these directly rf prompts create assistant --content "You are a helpful assistant" rf prompts update assistant --content "Updated instructions" rf prompts delete old-prompt --yes ``` ## Automation & Scripts Use in bash scripts or CI/CD: ```bash theme={null} #!/bin/bash # Deploy multiple prompts prompts_dir="./prompts" for file in "$prompts_dir"/*.txt; do name=$(basename "$file" .txt) rf prompts create "$name" --file "$file" done # Backup existing prompts rf prompts list | while read -r prompt; do rf prompts get "$prompt" > "backups/$prompt.txt" done ``` ## Integration with Agents Prompts are typically used in your agent code: ```typescript theme={null} import { Agent } from '@runflow-ai/core'; const agent = new Agent({ name: 'Support Bot', instructions: await getPrompt('customer-support'), // Load from prompts model: 'gpt-4', // ... other config }); ``` ## Best Practices ### 1. Use Descriptive Names ```bash theme={null} # ✅ Good - clear purpose rf prompts create customer-support-v2 rf prompts create rag-retrieval-prompt rf prompts create sales-qualification # ❌ Avoid - unclear names rf prompts create prompt1 rf prompts create test rf prompts create x ``` ### 2. Version Your Prompts ```bash theme={null} rf prompts create support-bot-v1 rf prompts create support-bot-v2 rf prompts create support-bot-v3 # Keep old versions for rollback rf prompts get support-bot-v1 > backup/support-v1.txt ``` ### 3. Use Files for Complex Prompts ```bash theme={null} # For long, complex prompts rf prompts create complex-assistant --file ./prompts/complex.txt # Easier to: # - Edit in your IDE # - Version control # - Review changes ``` ### 4. Template Variables Use consistent variable naming: ```bash theme={null} # Use lowercase with underscores {{user_name}} {{company_name}} {{context}} # Or camelCase {{userName}} {{companyName}} {{contextData}} ``` ## Common Patterns ### Environment-Specific Prompts ```bash theme={null} # Development prompts rf switch dev rf prompts create system --content "Development mode prompt..." # Production prompts rf switch prod rf prompts create system --content "Production mode prompt..." ``` ### Prompt Library Management ```bash theme={null} # Export all prompts mkdir prompt-library rf prompts list | while read name; do rf prompts get "$name" > "prompt-library/$name.txt" done # Import prompts to new environment for file in prompt-library/*.txt; do name=$(basename "$file" .txt) rf prompts create "$name" --file "$file" done ``` ## Troubleshooting ### Prompt Already Exists ```bash theme={null} rf prompts create existing-prompt --content "..." # Error: Prompt 'existing-prompt' already exists ``` **Solution:** Use update instead or delete first: ```bash theme={null} rf prompts update existing-prompt --content "..." # OR rf prompts delete existing-prompt --yes rf prompts create existing-prompt --content "..." ``` ### Editor Not Opening (Interactive Mode) If the editor doesn't open: ```bash theme={null} # Set your preferred editor export EDITOR=nano rf prompts create my-prompt # Or use non-interactive mode rf prompts create my-prompt --content "Your content here" ``` ### File Not Found ```bash theme={null} rf prompts create test --file ./prompts/missing.txt # Error: File not found: ./prompts/missing.txt ``` **Solution:** Check the file path: ```bash theme={null} ls -la ./prompts/missing.txt # Use correct path rf prompts create test --file ./correct-path/file.txt ``` ## Aliases You can use singular or plural form: ```bash theme={null} rf prompt list # Same as rf prompts list rf prompt create # Same as rf prompts create rf prompt delete # Same as rf prompts delete ``` ## Next Steps Use prompts in your agents Create agent with custom prompts Combine prompts with RAG Test prompt changes locally # Self-Hosted & Multiple Environments Source: https://docs.runflow.ai/cli/self-hosted Point the CLI at your own RunFlow installation and switch between environments The CLI talks to RunFlow Cloud (`https://api.runflow.ai`) by default. If you run RunFlow in your own environment — or juggle several environments (cloud, staging, a customer install) — you point the CLI at each API **once** and it remembers everything for that profile: the API URL and the identity provider used to log in. ## Change the API once ```bash theme={null} rf login --api https://api.yourcompany.com ``` That single flag does three things: 1. **Resolves the target API** and shows it before opening the browser. 2. **Discovers the login provider** from the server, so you sign in against *your* identity provider — not `auth.runflow.ai`. 3. **Remembers both** in the active profile. Every later `rf login`, token refresh, and command reuses the same URL and provider — no need to pass `--api` again. Discovery is automatic. The CLI calls the installation's public `GET /runtime/auth/cli-config` endpoint to learn which OIDC provider to open. If the server doesn't publish it (older versions) or is unreachable, the CLI falls back to RunFlow Cloud defaults — so existing logins keep working. ## How the provider is resolved When you log in or refresh a token, the CLI resolves the auth provider in this order (first match wins): | Priority | Source | When to use | | -------- | --------------------------------------------- | ----------------------------------------------------------- | | 1 | `RUNFLOW_AUTH0_*` env vars | Force a provider locally (CI, testing) | | 2 | Server discovery (`/runtime/auth/cli-config`) | Normal self-hosted flow — nothing to configure on your side | | 3 | `auth` saved in the profile | Reusing a profile you already logged into | | 4 | RunFlow Cloud defaults | Cloud, or a server without the discovery endpoint | The API URL follows the same idea: `--api` > `RUNFLOW_API_URL` > the active profile's saved `api` > cloud default. ## Multiple environments as profiles Each profile remembers its own API URL and provider, so cloud and self-hosted installs live side by side. There's no separate "create profile" command — a profile is created (and activated) when you log in with `--profile`: ```bash theme={null} # Cloud (default provider) rf login --profile cloud # Your self-hosted staging install rf login --profile staging --api https://api.staging.yourcompany.com # A customer's install with their own identity provider rf login --profile acme --api https://runflow.acme.com ``` `rf login --profile ` both creates and activates that profile, so the last login is the active one. You only pass `--api` the first time — the URL and provider are remembered for that profile afterward. **Switching environments vs. tenants.** With a browser (OIDC) login, `rf switch` moves between the **tenants of the active account** — not between profiles. To activate a different environment, log into it again with `rf login --profile `. (API-key logins switch between profiles directly with `rf switch `.) See [Profiles](/cli/profiles). ## Signing out `rf logout` clears the stored tokens **but keeps** the API URL, provider, and tenant, so signing back in is a single `rf login` with no flags: ```bash theme={null} rf logout # current profile rf logout staging # a specific profile rf logout --all # every profile ``` ## Environment variable overrides Useful for CI or scripted runs where you don't want to persist a profile: | Variable | Overrides | | ------------------------- | ------------------------------------- | | `RUNFLOW_API_URL` | Target API base URL | | `RUNFLOW_AUTH0_DOMAIN` | Login provider domain | | `RUNFLOW_AUTH0_CLIENT_ID` | OIDC client ID used for login/refresh | | `RUNFLOW_AUTH0_AUDIENCE` | Token audience | Env vars take precedence over both server discovery and the saved profile. ## Where it's stored Connection settings are saved per profile in `~/.runflowrc` (YAML): ```yaml theme={null} currentProfile: staging profiles: staging: api: https://api.staging.yourcompany.com/api/v1/runtime auth: domain: https://login.yourcompany.com clientId: audience: https://api.staging.yourcompany.com tenantId: tenant_123 tenantName: Your Company ``` `~/.runflowrc` holds credentials. Keep it private and restrict permissions: ```bash theme={null} chmod 600 ~/.runflowrc ``` ## For self-hosted administrators For the CLI to discover *your* provider (instead of falling back to cloud), the api-portal serving the installation must expose the discovery endpoint and have these set: | api-portal env var | Purpose | | --------------------- | ---------------------------------------------------------------------------------------------------- | | `AUTH0_DOMAIN` | Your identity provider's domain — the CLI opens `/authorize` here | | `AUTH0_CLI_CLIENT_ID` | A **dedicated native/public (PKCE) client**, distinct from the portal SPA client (`AUTH0_CLIENT_ID`) | | `AUTH0_AUDIENCE` | The API audience for issued tokens | Register the native OIDC client on your identity provider with these callback URLs: ``` http://localhost:8630/callback http://localhost:8631/callback ... http://localhost:8640/callback ``` `AUTH0_DOMAIN` and `AUTH0_CLI_CLIENT_ID` must be set **together**. If only the domain is set, the CLI keeps the cloud client ID — which doesn't exist on your provider — and login fails. If neither is set, discovery returns nothing and the CLI opens RunFlow Cloud login instead. ## Next Steps Authentication and profile basics Manage and switch environments # Local Testing Source: https://docs.runflow.ai/cli/test Test agents locally with web interface and live reload The `rf test` command starts a **local development server** with a web interface for testing your agents. Features live reload, real-time monitoring, and zero configuration. ## Basic Usage ```bash theme={null} cd my-agent/ rf test ``` ## Features * ⚡ **Zero configuration** - Auto-detects agent from `.runflow/rf.json` * 🔄 **Live reload** - Automatically restarts on file changes * 🌐 **Web portal** with real-time monitoring * 🚀 **Auto browser** - Opens automatically at `http://localhost:PORT` * 📊 **Traces storage** - Saved locally in `.runflow/traces.json` * 🎯 **Smart ports** - Auto-selects available port (3000-4000) ## Options | Option | Description | Example | | ------------------- | -------------------------------- | ---------------------- | | `-p, --port ` | Specify port number | `rf test --port 4500` | | `--no-browser` | Don't open browser automatically | `rf test --no-browser` | ## Examples ### Start Test Server ```bash theme={null} # Start with default settings (auto-detects port, opens browser) cd agent-name/ rf test ``` The web interface will automatically open at: ``` http://localhost:PORT/agents/your-agent/test-monitor ``` ### Specify Port ```bash theme={null} # Start on specific port rf test --port 4500 ``` Access at: `http://localhost:4500/agents/your-agent/test-monitor` ### Without Browser ```bash theme={null} # Start server without opening browser rf test --no-browser ``` Then manually open: `http://localhost:PORT/agents/your-agent/test-monitor` ## How It Works 1. **Auto-detection**: The CLI reads `.runflow/rf.json` in the current directory to identify the agent 2. **Port Selection**: Automatically finds an available port between 3000-4000 3. **Web Server**: Starts a local server with full observability features 4. **Browser**: Opens the test monitor interface automatically (unless `--no-browser` is used) 5. **File Watcher**: Monitors your code for changes 6. **Live Reload**: Automatically restarts on file save 7. **Real-time Monitoring**: See execution traces, costs, and performance metrics in real-time 8. **Local Storage**: All traces are saved to `.runflow/traces.json` for analysis ## Live Reload The test server automatically detects file changes and reloads: ```bash theme={null} cd my-agent/ rf test # 1. Edit src/index.ts in your editor # 2. Save the file # 3. Server automatically detects changes # 4. Agent reloads with new code # 5. Test immediately in web interface ``` **Watched files:** * All `.ts` and `.js` files in `src/` * `package.json` * `.runflow/rf.json` No need to restart the server! Just save your files and test. ## Web Interface Features The local web portal provides: * 📊 **Real-time execution monitoring** * 💰 **Cost tracking** (tokens and costs per execution) * ⏱️ **Performance metrics** (duration, latency) * 🔍 **Trace inspection** (full execution details) * 🧪 **Interactive testing** (send test messages) * 📝 **Execution history** (stored locally) * 🔄 **Live reload status** (shows when code changes) ## Testing Workflow ```bash theme={null} # 1. Navigate to your agent directory cd my-agent/ # 2. Start test server (opens browser automatically) rf test # → Server started at http://localhost:3847 # → Browser opened # 3. Test your agent in web interface: # - Send test messages # - View real-time traces # - Check costs and performance # - Debug issues # 4. Make changes to your code # ... edit src/index.ts in your editor ... # ... save file ... # 5. Changes automatically reload (watch console) # → File changed: src/index.ts # → Reloading agent... # → Agent reloaded # 6. Test updated agent immediately (no restart needed) # 7. Repeat steps 4-6 until satisfied # 8. Stop server (Ctrl+C) and deploy rf agents deploy ``` ## Development Best Practices ### Rapid Iteration ```bash theme={null} # Terminal 1: Keep test server running cd my-agent/ rf test # Terminal 2 / IDE: Make changes # Edit code → Save → Test # Edit code → Save → Test # Repeat... ``` ### Testing with Knowledge Base ```bash theme={null} # 1. Create and populate KB rf kb create test-kb --embedding "OpenAI Small" rf kb upload test-kb ./test-docs --yes # 2. Update agent to use KB # ... edit src/index.ts to reference 'test-kb' ... # 3. Test with live reload rf test # → Make changes → Auto-reload → Test ``` ### Debugging The web interface shows detailed traces: ``` Execution: exec_abc123 Duration: 2.4s Cost: $0.0023 Status: Success Traces: └─ Agent Start ├─ KB Query: "user question" │ └─ Results: 3 chunks (0.92, 0.87, 0.81) ├─ LLM Call: gpt-4 │ └─ Tokens: 450 in, 120 out └─ Agent Complete ``` ## Local Traces All execution traces are saved to `.runflow/traces.json` in a structured format: ```json theme={null} { "executionId_1": { "traces": [...], "summary": {...} }, "executionId_2": { "traces": [...], "summary": {...} } } ``` This allows you to: * Analyze execution patterns * Debug issues offline * Track performance over time * Compare different executions ## Troubleshooting ### Port Already in Use If you see a port conflict error: ```bash theme={null} # Specify a different port rf test --port 4500 # Or let it auto-select rf test # → Port 3847 in use, trying 3848... ``` ### Agent Not Detected Make sure you're in the agent directory with `.runflow/rf.json`: ```bash theme={null} # Check if file exists ls .runflow/rf.json # If not, you're in the wrong directory cd path/to/my-agent/ rf test ``` **Error:** ``` Error: No agent configuration found ``` **Solution:** Navigate to correct directory or run `rf agents clone` first. ### Browser Doesn't Open If the browser doesn't open automatically: ```bash theme={null} # Start without browser and open manually rf test --no-browser # Check terminal for URL # Server started at http://localhost:3847 # Open manually in browser open http://localhost:3847 ``` ### Live Reload Not Working If changes aren't detected: 1. **Check file location** - Only files in `src/` are watched 2. **Save file properly** - Ensure file is actually saved 3. **Check console** - Look for reload messages 4. **Restart server** - Press Ctrl+C and run `rf test` again ```bash theme={null} # Manual restart if needed # Ctrl+C to stop rf test ``` ### Dependencies Not Installed ```bash theme={null} rf test # Error: Cannot find module 'some-package' ``` **Solution:** ```bash theme={null} npm install # or yarn install # or pnpm install # Then try again rf test ``` ### API Key Missing ```bash theme={null} rf test # Error: No API key found ``` **Solution:** ```bash theme={null} # Login first rf login # Then test rf test ``` ### Traces Not Saving If traces aren't saved to `.runflow/traces.json`: 1. **Check permissions** - Ensure write access to `.runflow/` 2. **Check disk space** - Ensure sufficient space 3. **Check file** - `cat .runflow/traces.json` ```bash theme={null} # Fix permissions if needed chmod -R 755 .runflow/ # Test again rf test ``` ## Advanced Usage ### Custom Port ```bash theme={null} # Use specific port (useful for consistent URLs) rf test --port 3000 # Always accessible at same URL # http://localhost:3000 ``` ### Multiple Agents ```bash theme={null} # Terminal 1 cd agent-1/ rf test --port 3000 # Terminal 2 cd agent-2/ rf test --port 3001 # Terminal 3 cd agent-3/ rf test --port 3002 ``` ### CI/CD Testing ```bash theme={null} #!/bin/bash # Test agent in CI pipeline cd my-agent/ rf test --no-browser --port 4000 & TEST_PID=$! # Wait for server to start sleep 5 # Run automated tests against http://localhost:4000 curl http://localhost:4000/health npm run test:integration # Stop test server kill $TEST_PID ``` ## Performance Tips ### Fast Iteration * Keep server running - don't restart * Use live reload - save and test * Monitor console for reload status * Check traces for bottlenecks ### Debugging Slow Responses The web interface shows timing breakdown: ``` Execution Time: 5.2s ├─ KB Query: 1.2s ├─ LLM Call: 3.8s └─ Other: 0.2s ``` Identify and optimize slow components. ## Next Steps Deploy your tested agent Test with knowledge base Test prompt changes Learn about observability features # Configuration File Source: https://docs.runflow.ai/configuration/config-file Configure Runflow SDK with .runflow/rf.json ## Configuration File Create a `.runflow/rf.json` file: ```json theme={null} { "agentId": "your_agent_id", "tenantId": "your_tenant_id", "apiKey": "your_api_key", "apiUrl": "http://localhost:3001" } ``` The SDK automatically searches for `.runflow/rf.json` in the current directory and parent directories. When you run `rf create` or `rf agents clone`, the `.runflow/rf.json` file is created automatically with the correct values. ## Using config outside the SDK By default, the SDK reads `rf.json` internally when creating agents and API clients. But if you need the config available to **external tools** (Promptfoo, custom test scripts, CI pipelines, or any Node.js process), add a single import: ```typescript theme={null} import '@runflow-ai/sdk/init'; ``` This reads `.runflow/rf.json` and sets the following environment variables in `process.env`: | rf.json field | Environment variable | | ------------- | -------------------- | | `apiUrl` | `RUNFLOW_API_URL` | | `apiKey` | `RUNFLOW_API_KEY` | | `tenantId` | `RUNFLOW_TENANT_ID` | | `agentId` | `RUNFLOW_AGENT_ID` | Existing environment variables are **never overwritten** — explicit env vars always take priority over rf.json values. ### Examples **Promptfoo config:** ```typescript promptfooconfig.ts theme={null} import '@runflow-ai/sdk/init'; // process.env.RUNFLOW_API_URL is now available export default { providers: [{ id: 'runflow', config: { apiUrl: process.env.RUNFLOW_API_URL }, }], }; ``` **Custom test script:** ```typescript test.ts theme={null} import '@runflow-ai/sdk/init'; import { main } from './main'; // Your agent can resolve its config automatically const result = await main({ message: 'Hello' }); console.log(result); ``` **Any standalone script:** ```typescript scripts/check-agent.ts theme={null} import '@runflow-ai/sdk/init'; console.log('Agent ID:', process.env.RUNFLOW_AGENT_ID); console.log('API URL:', process.env.RUNFLOW_API_URL); ``` `init` is idempotent — safe to import multiple times from different files. It reads `rf.json` once and skips subsequent calls. ## Configuration Priority 1. Explicit config in code 2. `.runflow/rf.json` 3. Environment variables 4. Defaults ## Next Steps Authenticate and manage profiles Resolve common issues # Agents Source: https://docs.runflow.ai/core-concepts/agents Learn how to create and configure intelligent AI agents Agents are the fundamental building blocks of the Runflow SDK. Each agent is configured with: * **Name**: Agent identifier * **Instructions**: Behavior instructions (system prompt) * **Model**: LLM model to use (OpenAI, Anthropic, Bedrock, Groq, Gemini, Azure OpenAI, or custom) * **Tools**: Available tools for the agent * **Memory**: Memory configuration * **RAG**: Knowledge base search configuration ## Complete Agent Configuration ```typescript theme={null} import { Agent, anthropic } from '@runflow-ai/sdk'; const agent = new Agent({ name: 'Advanced Support Agent', instructions: `You are an expert customer support agent. - Always be polite and helpful - Solve problems efficiently - Use tools when needed`, // Model model: anthropic('claude-3-5-sonnet-20241022'), // Model configuration modelConfig: { temperature: 0.7, maxTokens: 4000, topP: 0.9, frequencyPenalty: 0, presencePenalty: 0, }, // Memory memory: { maxTurns: 20, summarizeAfter: 50, summarizePrompt: 'Create a concise summary highlighting key points and decisions', summarizeModel: openai('gpt-4o-mini'), // Cheaper model for summaries }, // RAG (Agentic - LLM decides when to search) rag: { vectorStore: 'support-docs', k: 5, threshold: 0.7, searchPrompt: 'Use for technical questions', }, // Tools tools: { createTicket: ticketTool, searchOrders: orderTool, }, // Tool iteration limit maxToolIterations: 10, // Streaming streaming: { enabled: true, }, // Debug mode debug: true, }); ``` ## Supported Models ```typescript theme={null} import { openai, anthropic, bedrock, groq, gemini, custom } from '@runflow-ai/sdk'; // OpenAI const gpt4 = openai('gpt-4o'); const gpt4mini = openai('gpt-4o-mini'); // Anthropic (Claude) const claude = anthropic('claude-sonnet-4-20250514'); const claudeHaiku = anthropic('claude-3-5-haiku-20241022'); // AWS Bedrock const claudeBedrock = bedrock('anthropic.claude-3-5-sonnet-20241022-v2:0'); const titan = bedrock('amazon.titan-text-express-v1'); // Groq (ultra-fast inference) const llama = groq('llama-3.3-70b-versatile'); const llamaFast = groq('llama-3.1-8b-instant'); // Google Gemini const flash = gemini('gemini-2.5-flash'); const pro = gemini('gemini-2.5-pro'); // Custom (OpenAI-compatible: Ollama, vLLM, LiteLLM, etc.) const local = custom('llama3', 'Ollama Local'); ``` See [LLM Providers](/providers/llm-provider) for full details on configuring providers, credentials, and named configurations. ## Agent Methods ```typescript theme={null} // Process a message const result = await agent.process(input: AgentInput): Promise; // Stream a message const stream = await agent.processStream(input: AgentInput): AsyncIterable; // Simple generation (without full agent context) const response = await agent.generate(input: string | Message[]): Promise<{ text: string }>; ``` ## Multi-Agent Systems (Supervisor Pattern) Add the `agents` field to create a supervisor that automatically routes requests to specialized child agents using LLM-based intent classification: ```typescript theme={null} const supervisor = new Agent({ name: 'Customer Service', instructions: `Route requests to the right specialist: - Sales: pricing, plans, purchases - Support: technical issues, bugs, how-to`, model: openai('gpt-4o-mini'), // Cheap model for routing agents: { support: { name: 'Support Agent', instructions: 'Solve technical problems step by step.', model: openai('gpt-4o'), tools: { searchOrders: orderTool }, rag: { vectorStore: 'support-docs', k: 5 }, }, sales: { name: 'Sales Agent', instructions: 'Handle sales inquiries. Be consultative.', model: openai('gpt-4o'), }, }, memory: { maxTurns: 30 }, }); // Supervisor analyzes intent and routes automatically await supervisor.process({ message: 'I want to buy your product', sessionId: 'session_123', }); ``` Each child agent can have its own model, tools, RAG, and memory. The supervisor uses a cheap model for routing while specialists use powerful models for quality responses. See the dedicated [Supervisor guide](/core-concepts/supervisor) for routing logic, cost optimization, fallback behavior, and configuration reference. ## Debug Mode ```typescript theme={null} const agent = new Agent({ name: 'Debug Agent', instructions: 'Help users', model: openai('gpt-4o'), // Simple debug (all logs enabled) debug: true, // Or detailed debug configuration debug: { enabled: true, logMessages: true, // Log messages logLLMCalls: true, // Log LLM API calls logToolCalls: true, // Log tool executions logRAG: true, // Log RAG searches logMemory: true, // Log memory operations truncateAt: 1000, // Truncate logs at N characters }, }); ``` ## Invoking other agents An agent can invoke any other agent in the same tenant via the cross-agent SDK. Useful for reviewer / metrics / follow-up patterns where one agent orchestrates another. ```typescript theme={null} import { Agents } from '@runflow-ai/sdk/agents'; const agents = new Agents(); // Sync — wait for the result. Accepts UUID, slug, or unique name. const result = await agents.invoke('customer-support', { message: 'Resumo das últimas 24h', }); // Async — fire and forget await agents.invokeAsync('customer-support', { message: 'Olá, tudo bem?', userId: '+5511999999999', channel: 'follow-up', }); ``` All operations are tenant-scoped. See [Cross-Agent SDK](/core-concepts/cross-agent) for invocation, executions reading, threads, memory administration, and the full security model. ## Next Steps Learn about memory management Invoke other agents, read executions, manage their memory Create custom tools # Channels Source: https://docs.runflow.ai/core-concepts/channels Connect agents to WhatsApp, Telegram, Kommo, Chatwoot or any webhook — normalized inbound events and channel-aware replies, without rewriting the plumbing The **Channels** module (`@runflow-ai/sdk/channels`) is a thin **translation layer** between messaging channels and your agent. It does two things: * **Parses** each raw webhook request into a normalized `InboundEvent` * **Renders and sends** channel-agnostic `OutboundMessage`s, absorbing each channel's quirks (text chunking, button limits, media fixups, interactive fallbacks) Available from `@runflow-ai/sdk` **v1.5.0**: ```typescript theme={null} import { createChannelHandler, meta, reply } from '@runflow-ai/sdk/channels'; ``` Everything is **explicit — no magic**. You import a provider factory (`meta()`, `telegram()`, …) and pass it. There is no global registry and no side-effect imports. Outbound messages are POSTed through the SDK [connector](/core-concepts/connectors), so credentials stay server-side — your agent code never holds a channel token. ## Who does what The module deliberately owns **no flow control** and imposes **no state contract**: | Layer | Responsibility | | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Platform** (HTTP trigger) | Flow control: coalesces the inbound burst by a configured key (debounce), dedup, ordering, retry, webhook auth. Delivers one turn with `input.events[]` already grouped — the coalescing key doubles as the user identity. | | **Channels module** (this page) | Translation only: raw request → `InboundEvent`, `OutboundMessage` → channel wire format. | | **Your project** | The turn: agent + domain logic, prompts, and your own state. | When a user sends 3 quick messages, the platform groups them into a single execution and your handler receives all 3 events at once — no manual debouncing. ## Quick start Want the full zero-to-live walkthrough — connector, credential, agent URL with `raw=true` and Meta's webhook handshake? Follow [WhatsApp Agent from Scratch](/use-cases/whatsapp-agent-from-scratch). The whole entry point: ```typescript main.ts theme={null} import { createChannelHandler, meta } from '@runflow-ai/sdk/channels'; import { runTurn } from './agent/runner'; export default createChannelHandler({ provider: meta({ connector: 'whatsapp-acme', phoneNumberId: '123456789012345' }), run: runTurn, }); ``` Your domain logic receives the whole coalesced burst, already parsed: ```typescript agent/runner.ts theme={null} import { reply, type RunTurn } from '@runflow-ai/sdk/channels'; import { agent } from './agent'; export const runTurn: RunTurn = async ({ ctx, events }) => { const text = events .map((e) => ('text' in e ? e.text : `[${e.type}]`)) .join('\n'); const result = await agent.process({ message: text, userId: ctx.userId, // = the coalescing key = the identity }); return [reply.text(result.message)]; }; ``` Return an array of messages and the provider renders each one for the channel — chunking, button limits and media formats are handled for you. ## Inbound events `InboundEvent` is a discriminated union. Every event carries `userId` (the stable conversation key), plus `messageId`, `profileName` and `raw` (the original payload, as an escape hatch) when the channel provides them. | `type` | Payload fields | Notes | | ------------ | ----------------------------------------------- | ------------------------------------------------------------------- | | `text` | `text` | Plain message | | `button` | `payload`, `text` | Quick-reply / inline button tap | | `audio` | `mediaId`, `mimeType?` | Voice note — resolve with [`fetchMedia`](#media-audio-images-files) | | `image` | `mediaId`, `mimeType?`, `caption?` | | | `video` | `mediaId`, `mimeType?`, `caption?` | | | `document` | `mediaId`, `mimeType?`, `filename?`, `caption?` | | | `location` | `latitude`, `longitude`, `name?` | | | `contacts` | `contacts: { name, phone }[]` | Shared contact cards | | `flow-reply` | `responseData` | WhatsApp Flow form submission | | `unknown` | `messageType` | Unrecognized payload — inspect `raw` | Malformed events never take down the burst: each event is parsed in isolation, and non-message payloads (status callbacks, a bot's own messages) are silently skipped. ## Replying Build outbound messages with the `reply` helpers: ```typescript theme={null} import { reply } from '@runflow-ai/sdk/channels'; return [ reply.text('Pedido confirmado!'), reply.buttons('Como quer receber?', [ { id: 'pickup', label: 'Retirar na loja' }, { id: 'delivery', label: 'Entrega' }, ]), reply.image('https://cdn.acme.com/receipt.png', 'Seu comprovante'), reply.document('https://cdn.acme.com/contract.pdf', { filename: 'contrato.pdf' }), reply.audio('https://cdn.acme.com/voice.ogg'), reply.link('Acompanhe seu pedido', 'Rastrear', 'https://acme.com/track/123'), reply.flow('1234567890', 'Finalize seu cadastro', 'Abrir formulário'), ]; ``` Messages are **channel-agnostic** — the provider renders each type to the channel's wire format and **degrades gracefully** where the channel lacks a feature (e.g. buttons become a numbered text list on channels without interactive messages). Replies are sent **sequentially** (chat order matters). If one fails, the remaining messages are marked `skipped` — never attempted — so a platform retry can resend the tail without duplicating the head. ## Providers ### WhatsApp Cloud — `meta()` ```typescript theme={null} meta({ connector: 'whatsapp-acme', phoneNumberId: '123456789012345' }) ``` | Option | Default | Description | | --------------- | ----------------- | ----------------------------------------------------------------------------- | | `connector` | trigger binding | Connector instance slug | | `phoneNumberId` | trigger binding | The `phone_number_id` used on the send URL | | `sendResource` | `'send-message'` | Resource slug for sending (passthrough to `POST /{phone_number_id}/messages`) | | `mediaResource` | `'get-media-url'` | Resource slug for resolving inbound media ids | The Meta provider bakes in real production lessons so you don't relearn them: * Text clipped at 4,096 chars; interactive bodies at 1,024 (avoids error `131009`) — a longer body is sent as text followed by the buttons * Max 3 buttons (Meta's limit) — zero or more than 3 degrade to a numbered text list; button titles clipped at 20 chars * `.webp` image URLs rewritten to `.jpg` (Meta rejects webp with error `131053`, often silently) * Brazilian phone numbers normalized (legacy 12-digit numbers get the mobile `9` inserted — landlines untouched) * Typing indicator piggybacks on mark-as-read, through the same send resource * `reply.flow()` renders a native WhatsApp Flow message; inbound Flow submissions arrive as `flow-reply` events `reply.flow()` belongs to this channel-agnostic layer and degrades to text where a channel has no equivalent. Inside the Conversation Hub the same message is `conv.sendInteractive({ type: 'flow', ... })`, which also carries the `flowToken` that comes back with the submission — see [Conversation Hub SDK](/core-concepts/conversation-hub). They are different APIs, not aliases. #### Automatic number sync (Conversation Hub) When the Conversation Hub addon manages your WABA, new phone numbers registered on the WhatsApp Business Account are **imported automatically** — a periodic sync (every 15 minutes by default, discovers numbers that have no connection yet and creates one for each, inheriting credentials from a sibling connection of the same WABA. Inbound webhooks are re-routed by the payload's `phone_number_id`, so messages to a freshly imported number land on the right connection from the first message. In practice: connect the WABA once, and every number your team registers afterwards shows up in the portal on its own — no per-number manual setup. An imported number starts with no 1:1 agent link. Route it by setting the WABA's **default agent** (below) or by linking an agent to the connection in the portal. ### Telegram — `telegram()` ```typescript theme={null} telegram({ connector: 'telegram-acme' }) ``` | Option | Default | Description | | ----------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `connector` | trigger binding | Connector instance slug (bot token lives in the connector base URL) | | `resources` | Bot API names in kebab-case | Override any resource slug: `sendMessage` → `send-message`, `sendPhoto` → `send-photo`, `sendVoice` → `send-voice`, `sendDocument` → `send-document`, `sendChatAction` → `send-chat-action`, `getFile` → `get-file` | Long texts are chunked at 4,096 chars, captions clipped at 1,024, and button ids clipped at Telegram's 64-**byte** `callback_data` limit. `reply.link()` and `reply.flow()` degrade to plain text (no Telegram equivalent). ### Kommo CRM — `kommo()` Kommo is **CRM-as-channel**: it has no direct messaging API, so outbound = patch a lead custom field with the reply, then run the salesbot that delivers it. ```typescript theme={null} kommo({ connector: 'kommo-acme', replyFieldId: 987654 }) ``` | Option | Default | Description | | ------------------ | ----------------------- | ------------------------------------------- | | `connector` | trigger binding | Connector instance slug | | `replyFieldId` | trigger binding address | The lead custom-field id the salesbot reads | | `patchResource` | `'patch-lead'` | Resource that PATCHes the lead custom field | | `salesbotResource` | `'run-salesbot'` | Resource that triggers the salesbot | The `userId` is the **lead id**. All rich message types degrade to plain text (buttons become a numbered list) — the salesbot field only carries text. ### Chatwoot — `chatwoot()` Chatwoot is **helpdesk-as-channel**, conversation-based: ```typescript theme={null} chatwoot({ connector: 'chatwoot-acme', accountId: 7 }) ``` | Option | Default | Description | | -------------- | ----------------------- | --------------------------------------------------- | | `connector` | trigger binding | Connector instance slug | | `accountId` | trigger binding address | Chatwoot account id (path param on every call) | | `sendResource` | `'send-message'` | Resource that creates a message in the conversation | The `userId` is the **conversation id** — Chatwoot's native thread and the send target. The provider includes **bot-loop prevention**: `outgoing`, `agent_bot` and agent (`user`) messages are ignored, so the agent never answers itself. ### Generic webhooks — `custom()` For an HTTP trigger fed by an arbitrary system (CRM, ERP, internal service), `custom()` replaces hand-rolled parsing. Declare where the fields live — a dot-path into the body or a function: ```typescript theme={null} import { createChannelHandler, custom } from '@runflow-ai/sdk/channels'; export default createChannelHandler({ provider: custom({ text: 'data.text', // dot-path (numeric segments index arrays) userId: 'contact.id', // numbers coerce to string messageId: (body) => body.event_id, }), run: async ({ events }) => { // inbound-only by default: return [] and reply through your own transport return []; }, }); ``` | Option | Default | Description | | ----------- | ----------- | --------------------------------------------------------------------------------------------------------------- | | `text` | `'message'` | Where the message text lives | | `userId` | `'userId'` | Where the user/conversation id lives (usually the trigger's coalescing key field) | | `messageId` | `'msgId'` | Provider message id, for tracing | | `parse` | — | Full override: `(rawBody) => InboundEvent \| null` — wins over the extractors | | `send` | — | Optional outbound: without it, replies fail with a clear error (a generic webhook has no implied way to answer) | Events without text are surfaced as `{ type: 'unknown', raw }` instead of being dropped, so your turn can still inspect data-only webhooks. ## Connector resources Providers send through a **connector resource that you control**. The provider builds the correct API payload; your resource forwards it. Defaults match the catalog connectors, so **Meta, Telegram and Chatwoot work out of the box** — override the slugs to point at your own connector: | Provider | Catalog connector | Default resources | | ---------- | ----------------------- | ------------------------------------------------------------------------------------------- | | `meta` | WhatsApp Business Cloud | `send-message`, `get-media-url` | | `telegram` | Telegram Bot | `send-message`, `send-photo`, `send-voice`, `send-document`, `send-chat-action`, `get-file` | | `chatwoot` | Chatwoot | `send-message` | | `kommo` | — (create your own) | `patch-lead`, `run-salesbot` | ```typescript theme={null} // Point at your own connector + resource slugs meta({ connector: 'meta-acme', sendResource: 'enviar-msg', phoneNumberId: '...' }); ``` Connector and address resolve from **explicit factory options first**, then from the trigger's channel binding (`ctx.binding`) when your trigger delivers one. If neither supplies what the wire needs, the provider fails loud and early with a legible error. ## Multi-channel routing `provider` can be a resolver — route per turn (e.g. by tenant or by binding): ```typescript theme={null} createChannelHandler({ provider: (ctx) => ctx.binding?.provider === 'meta' ? meta() : telegram(), run: runTurn, }); ``` Because `InboundEvent` and `OutboundMessage` are channel-agnostic, the same `runTurn` serves every channel. ### Default agent per WABA (Conversation Hub) In the Conversation Hub, each WhatsApp Business Account can carry a **default agent**: any number of that WABA with no agent of its own routes inbound conversations to it. Combined with [automatic number sync](#automatic-number-sync-conversation-hub), a newly registered number is answered by the right agent with zero per-number configuration. Inbound routing precedence, first match wins: 1. **Conversation's agent** — a thread that already has an agent keeps it 2. **Connection's 1:1 linked agent** — an explicit number → agent link 3. **WABA's default agent** — set by an admin on the portal's Connections page 4. **Department fallback** — first active agent of the conversation's department If nothing matches, the conversation stays in the human queue — the message is persisted and visible in the inbox, it just gets no AI auto-reply. ## Media (audio, images, files) The boundary: **channels fetches, the SDK understands.** `provider.fetchMedia()` resolves a media event into something fetchable; transcription, vision and OCR stay in the SDK's [media layer](/core-concepts/media-processing). ```typescript theme={null} import { transcribe } from '@runflow-ai/sdk'; export const runTurn: RunTurn = async ({ ctx, events, provider }) => { const audio = events.find((e) => e.type === 'audio'); if (audio) { const file = await provider.fetchMedia!(ctx, audio); // channel-specific const { text } = await transcribe({ audioUrl: file.url! }); // channel-agnostic return [reply.text(`Você disse: ${text}`)]; } return [reply.text('Envie um áudio!')]; }; ``` Per-channel behavior: | Provider | `fetchMedia` behavior | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `meta` | Resolves the media id via the Graph API. The returned URL is **Bearer-gated** — to get raw bytes, use a custom `mediaResource` that returns `{ base64 }` (the `{ download: true }` option is passed through as a hint) | | `telegram` | Returns a directly usable download URL by default; `{ download: true }` fetches the bytes | | `kommo` / `chatwoot` | The inbound media id is already a public URL — returned as-is | Credentials stay in the connector, server-side: the agent never holds the channel token. ## Typing indicators On by default where the channel supports it (Meta, Telegram) — best-effort, never blocks the turn: ```typescript theme={null} createChannelHandler({ provider: meta({ ... }), run: runTurn, typing: (e) => e.type === 'audio', // false to disable, or a per-event predicate }); ``` ## Glass box: hand-rolling the handler `createChannelHandler` is sugar over exported pieces — hand-roll `main` if you want the whole flow in sight: ```typescript theme={null} import { meta, parseEvents, sendAll } from '@runflow-ai/sdk/channels'; import { runTurn } from './agent/runner'; export default async function main(input: any) { const provider = meta({ connector: 'whatsapp-acme', phoneNumberId: '...' }); const events = parseEvents(provider, input); // parse + per-event isolation if (!events.length) return { ok: true, skipped: true }; const ctx = { ...input.context, userId: input.context?.userId ?? events[0].userId }; const replies = await runTurn({ ctx, events, provider }); const results = await sendAll(provider, ctx, replies); // sequential, per-message results return { ok: results.every((r) => r.status === 'sent'), results }; } ``` The handler returns `{ ok, sent, results }`, where each `SendResult` is: | `status` | Meaning | | --------- | --------------------------------------------------------------------------------------------------- | | `sent` | Delivered to the channel API | | `failed` | Attempted and errored (`error` has the message) | | `skipped` | Never attempted because a prior message failed (`reason: 'prior_failed'`) — safe to resend on retry | `parseEvents` reads `input.events[]` — the platform delivers each coalesced request as `{ method, path, query, headers, body, receivedAt }` and the channel payload is unwrapped from `.body` automatically. Inputs with a single `request.body` (no batching) also work. ## Bring your own channel A provider is just a factory returning the `ChannelProvider` contract — `parse` + `send`, with optional `typing` and `fetchMedia`. No registry to update, no handler changes: ```typescript theme={null} import type { ChannelProvider, InboundEvent, OutboundMessage, TurnContext } from '@runflow-ai/sdk/channels'; export function myChannel(opts: { connector: string }): ChannelProvider { return { name: 'custom', parse(rawBody): InboundEvent | null { const b = rawBody as any; if (!b?.from) return null; // not a message → skip silently, never throw return { type: 'text', text: b.message ?? '', userId: String(b.from), raw: b }; }, async send(ctx: TurnContext, to: string, message: OutboundMessage) { // render `message` to your wire format and POST via a connector resource }, }; } ``` Rules of the contract: `parse` must be defensive (return `null` for non-messages, never throw on malformed payloads) and `send` should throw on hard failures so the handler records a per-message result. ## Next Steps The transport channels send through — credentials stay server-side Transcribe audio and process images from inbound events The turn your channel handler runs Persist conversation state across turns # Connectors Source: https://docs.runflow.ai/core-concepts/connectors Dynamic integrations with external services **Connectors** are dynamic integrations with external services defined in the Runflow backend. They support two modes of usage: 1. **As Tools** - For agent execution (LLM decides when to call) 2. **Direct Invocation** - For programmatic execution (you control when to call) ## Key Features * 🔄 **Dynamic Schema Loading** - Schemas are fetched from the backend automatically * 🎭 **Transparent Mocking** - Enable mock mode for development and testing * 🛣️ **Path Parameter Resolution** - Automatic extraction and URL building * ⚡ **Lazy Initialization** - Schemas loaded only when needed, cached globally * 🔐 **Flexible Authentication** - Supports API Key, Bearer Token, Basic Auth, OAuth2 * 🔄 **Multiple Credentials** - Override credentials per execution (multi-tenant support) * ✅ **Type-Safe** - Automatic JSON Schema → Zod → LLM Parameters conversion ## Usage Mode 1: As Agent Tool Use connectors as tools that the LLM can call automatically **Resource Identifier:** Use the **resource slug** (e.g., `get-customers`, `list-users`) which is auto-generated from the resource name. Slugs are stable, URL-safe identifiers that won't break if you rename the resource display name. ```typescript theme={null} import { createConnectorTool, Agent, openai } from '@runflow-ai/sdk'; // Basic connector tool (schema loaded from backend) const getClienteTool = createConnectorTool({ connector: 'api-contabil', // Connector instance slug resource: 'get-customers', // Resource slug description: 'Get customer by ID from accounting API', enableMock: true, // Optional: enables mock mode }); // Use with Agent const agent = new Agent({ name: 'Accounting Agent', instructions: 'You help manage customers in the accounting system.', model: openai('gpt-4o'), tools: { getCliente: getClienteTool, listClientes: createConnectorTool({ connector: 'api-contabil', resource: 'list-customers', // Resource slug }), }, }); // First execution automatically loads schemas from backend const result = await agent.process({ message: 'Get customer with ID 123', sessionId: 'session-123', companyId: 'company-456', }); ``` ## Usage Mode 2: Direct Invocation Call connectors programmatically, without agent involvement. Works anywhere: standalone scripts, workflow steps, API handlers. `connector()` is a **function call**, not a client factory. You must pass all 3 arguments (connector slug, resource slug, data) in a single call. It returns a `Promise` with the result. ```typescript theme={null} // CORRECT const result = await connector('hubspot-prod', 'create-contact', { email: 'john@example.com' }); // WRONG - connector() needs 3 args, not 1 const client = connector('hubspot-prod'); await client.execute('create-contact', data); // TypeError! ``` **Identifiers:** * **Connector:** Use the instance **slug** (e.g., `hubspot-prod`) - recommended over display name * **Resource:** Use the resource **slug** (e.g., `create-contact`) - auto-generated from resource name ### Response Format The response is wrapped in a standard envelope: ```typescript theme={null} const result = await connector('hubspot-prod', 'create-contact', { email: 'john@example.com' }); // result = { // success: true, // data: { id: 'contact_123', email: 'john@example.com', ... }, // actual API response // metadata: { connector: 'hubspot-prod', resource: 'create-contact', ... } // } // Access the actual data: const contact = result.data; ``` ### Examples ```typescript theme={null} import { connector } from '@runflow-ai/sdk'; // Direct connector call (using slugs - recommended) const result = await connector( 'hubspot-prod', // connector instance slug 'create-contact', // resource slug { // data email: 'john@example.com', firstname: 'John', lastname: 'Doe' } ); console.log('Contact created:', result); ``` **With execution options:** ```typescript theme={null} const options: ConnectorExecutionOptions = { credentialId: 'cred-prod-123', // Override credential timeout: 10000, // 10 seconds timeout retries: 3, // Retry 3 times on failure useMock: false, // Use real API }; const result = await connector( 'api-contabil', 'get-customer', // Resource slug { id: 123 }, options ); ``` **Multi-tenant example:** ```typescript theme={null} // Different credentials per customer async function createContactForCustomer(customerId: string, contactData: any) { // Get customer's HubSpot credential const credentialId = await getCustomerCredential(customerId, 'hubspot'); return await connector( 'hubspot', 'create-contact', // Resource slug contactData, { credentialId } ); } // Usage await createContactForCustomer('customer-1', { email: 'john@acme.com' }); await createContactForCustomer('customer-2', { email: 'jane@techcorp.com' }); ``` **Authentication Priority:** 1. **Custom headers** (highest - overrides everything) 2. **credentialId override** (runtime override) 3. **Instance credential** (default from connector instance) 4. **No authentication** ### Public APIs — call a template directly (no instance) For connector **templates** with `authRequired = false` (public APIs imported via OpenAPI, like JSONPlaceholder, public REST endpoints, etc.), you can skip the instance step entirely and pass the **template slug** as the first argument. The resolver tries an instance match first and transparently falls back to the template when no instance exists. ```typescript theme={null} import { connector } from '@runflow-ai/sdk'; // Template slug (no instance created) const todo = await connector( 'jsonplaceholder', // template slug — no `-prod` instance needed 'get-todo', { id: 1 } ); ``` This works for both `connector(...)` and `createConnectorTool({ connector: 'slug', ... })`. The shortcut only kicks in when the template has no auth — anything requiring a credential still needs an instance. ## Using Connectors in Workflows ### Inside `.step()` (recommended) Call `connector()` directly inside a workflow step: ```typescript theme={null} import { flow, connector } from '@runflow-ai/sdk'; import { z } from 'zod'; const workflow = flow({ id: 'crm-pipeline', inputSchema: z.object({ cpf: z.string(), name: z.string() }), outputSchema: z.any(), }) .step('check-eligibility', async (input) => { const result = await connector( 'api-elegibilidade', // connector slug 'consulta-por-cpf', // resource slug { path: { cpf: input.cpf } } // data (path params, query, body) ); return { eligible: result.status === 'active', data: result }; }) .step('create-contact', { handler: async (input, ctx) => { const contact = await connector('hubspot-prod', 'create-contact', { email: `${ctx.input.cpf}@example.com`, firstname: ctx.input.name, properties: { eligibility: input.eligible ? 'approved' : 'denied' }, }); return { contactId: contact.id, eligible: input.eligible }; }, when: (ctx) => ctx.results['check-eligibility'].eligible, }) .build(); ``` ### Using `.connector()` step (native) The FlowBuilder also has a native `.connector()` method for simple cases: ```typescript theme={null} flow({ id: 'simple', inputSchema, outputSchema }) .connector('create-ticket', 'hubspot', 'tickets', 'create', { subject: 'New Support Request', content: '{{input.description}}', priority: 'medium', }) .build(); ``` The native `.connector()` step uses template interpolation (`{{input.field}}`) for parameters. For dynamic logic (conditionals, transformations), use `connector()` inside a `.step()` instead. ## Using loadConnector Helper For connectors with many resources, use the `loadConnector` helper: ```typescript theme={null} import { loadConnector } from '@runflow-ai/sdk'; const contabil = loadConnector('api-contabil'); const agent = new Agent({ name: 'Accounting Agent', instructions: 'You manage accounting data.', model: openai('gpt-4o'), tools: { // Using resource slugs listClientes: contabil.tool('list-customers'), getCliente: contabil.tool('get-customer'), createCliente: contabil.tool('create-customer'), updateCliente: contabil.tool('update-customer'), }, }); ``` ## Path Parameters Connectors automatically resolve path parameters from the resource URL: ```typescript theme={null} // Resource defined in backend with path: /clientes/{id}/pedidos/{pedidoId} const getClientePedidoTool = createConnectorTool({ connector: 'api-contabil', resource: 'get-customer-order', // Resource slug description: 'Get specific order from a customer', }); // Agent automatically extracts path params from context const result = await agent.process({ message: 'Get order 456 from customer 123', sessionId: 'session-123', companyId: 'company-456', }); // Backend automatically resolves: /clientes/123/pedidos/456 ``` ## Mock Execution Enable mock mode for development and testing: ```typescript theme={null} const tool = createConnectorTool({ connector: 'api-contabil', resource: 'list-customers', // Resource slug enableMock: true, // Adds useMock parameter }); // Use mock mode in development const result = await agent.process({ message: 'List customers (use mock data)', sessionId: 'dev-session', companyId: 'dev-company', // Tool will automatically include useMock=true if mock data is configured }); ``` ## How It Works 1. **Tool Creation**: `createConnectorTool` creates a tool with a temporary schema 2. **Lazy Loading**: On first agent execution, schemas are fetched from the backend in parallel 3. **Schema Conversion**: JSON Schema → Zod → LLM Parameters (automatic) 4. **Caching**: Schemas are cached globally to avoid repeated API calls 5. **Execution**: Tool/API executes with authentication, path resolution, and error handling ## Next Steps Orchestrate complex processes Learn more about tools WhatsApp, Telegram and more — replies are sent through connectors # Context Management Source: https://docs.runflow.ai/core-concepts/context-management Manage execution information and user identification The **Runflow Context** is a global singleton that manages execution information and user identification. It allows you to identify once and all agents/workflows automatically use this context. ## Why Identify Matters When you call `identify()`, you're telling Runflow **who** is interacting with your agent. This single call connects three critical systems: * **Memory** — conversation history is stored and retrieved by this identifier. Same identifier = same conversation history. * **Traces** — all execution traces are linked to this user, so you can search and filter by user in the dashboard. * **Metrics** — business events emitted with `track()` are associated with this user. Without `identify()`, your agent still works, but memory won't persist correctly between sessions and your dashboard data won't be linked to specific users. ```typescript theme={null} import { identify } from '@runflow-ai/sdk/observability'; // Always call identify BEFORE agent.process() identify(userPhone); const result = await agent.process({ message, sessionId }); ``` ## Basic Usage ```typescript theme={null} import { Agent, openai } from '@runflow-ai/sdk'; import { identify } from '@runflow-ai/sdk/observability'; // Identify user by phone (WhatsApp) identify('+5511999999999'); // Agent automatically uses the context const agent = new Agent({ name: 'WhatsApp Bot', instructions: 'You are a helpful assistant.', model: openai('gpt-4o'), memory: { maxTurns: 10, }, }); // Memory is automatically bound to the phone number await agent.process({ message: 'Hello!', }); ``` ## Smart Identification (Auto-Detection) **New in v2.1:** The `identify()` function now auto-detects entity type from value format: ```typescript theme={null} import { identify } from '@runflow-ai/sdk/observability'; // Auto-detect email identify('user@example.com'); // → type: 'email', value: 'user@example.com' // Auto-detect phone (international) identify('+5511999999999'); // → type: 'phone', value: '+5511999999999' // Auto-detect phone (local with formatting) identify('(11) 99999-9999'); // → type: 'phone', value: '(11) 99999-9999' // Auto-detect UUID identify('550e8400-e29b-41d4-a716-446655440000'); // → type: 'uuid' // Auto-detect URL identify('https://example.com'); // → type: 'url' ``` **Supported patterns:** * **Email**: Standard RFC 5322 format * **Phone**: E.164 format (with/without +, with/without formatting) * **UUID**: Standard UUID v1-v5 * **URL**: With or without protocol * **Fallback**: Generic `id` type for custom identifiers ## Explicit Identification For custom entity types or when auto-detection is not desired: ```typescript theme={null} import { identify } from '@runflow-ai/sdk/observability'; // HubSpot Contact identify({ type: 'hubspot_contact', value: 'contact_123', userId: 'user@example.com', }); // Order/Ticket identify({ type: 'order', value: 'ORDER-456', userId: 'customer_789', }); // Custom threadId override identify({ type: 'document', value: 'doc_456', threadId: 'custom_thread_123', }); ``` ## Common Patterns ### WhatsApp / Phone Integration The phone number is the natural identifier. Memory persists across all conversations with the same number: ```typescript theme={null} identify('+5511999999999'); ``` ### Email-Based For web apps or email integrations: ```typescript theme={null} identify('user@example.com'); ``` ### Multi-Conversation When the same user can have multiple independent conversations (e.g., different support tickets): ```typescript theme={null} identify({ type: 'session', value: `${userEmail}:${conversationId}`, }); ``` ### Integration Webhooks Parse the identifier from the webhook payload: ```typescript theme={null} export async function main(input: any) { // Extract identifier from whatever your integration sends const userId = input.email || input.phone || input.userId; identify(userId); const result = await agent.process({ message: input.message, sessionId: input.sessionId }); return { message: result.message }; } ``` ## Backward Compatibility The old `Runflow.identify()` API still works but is not recommended. Prefer the direct import: ```typescript Recommended theme={null} import { identify } from '@runflow-ai/sdk/observability'; identify('user@example.com'); ``` ```typescript Legacy (still works) theme={null} import { Runflow } from '@runflow-ai/sdk/core'; Runflow.identify({ type: 'email', value: 'user@example.com', }); ``` ## State Management ```typescript theme={null} import { Runflow } from '@runflow-ai/sdk/core'; // Get complete state const state = Runflow.getState(); // Get specific value const threadId = Runflow.get('threadId'); const entityType = Runflow.get('entityType'); // Set custom state (advanced) Runflow.setState({ entityType: 'custom', entityValue: 'xyz', threadId: 'my_custom_thread_123', userId: 'user_123', metadata: { custom: 'data' }, }); // Clear state (useful for testing) Runflow.clearState(); ``` ## Next Steps Identify patterns and more tips Learn about memory management Track execution with observability See identify in action # Conversation Hub SDK Source: https://docs.runflow.ai/core-concepts/conversation-hub Reply to WhatsApp conversations, send media, hand off to humans, and manage contacts, tags and notes — all from agent code The **Conversation Hub SDK** (`@runflow-ai/sdk/conversation`) lets an agent operate inside the Conversation Hub — Runflow's WhatsApp/human-in-the-loop console. Answer the conversation that triggered the agent, show a typing indicator, send templates and media, open a ticket for a human attendant, and keep the contact's profile (tags, internal notes) up to date. Requires the **Conversation Hub** feature enabled for your tenant. All calls are authenticated with a tenant-scoped API key; conversations from other tenants return **404** (existence is never leaked). ## Setup One click: with the feature enabled, open **Credentials** in the Runflow portal, pick **Conversation Hub** in the credential type dropdown and hit **Generate key**. The key is minted by the Conversation Hub, stored encrypted as the `conversation-api-key` credential, and never shown in the browser. Re-running it **rotates** the key (the previous one is revoked instantly). That's it — the SDK resolves the key from the credentials store on the first call and talks to the production Conversation Hub by default. Just import the default instance: ```ts theme={null} import { conversation } from '@runflow-ai/sdk/conversation'; ``` ## Getting the conversation The conversation that activated this agent — the standard path for triggered agents. Lazy handle for a known conversation id. Proactive sends — resolve (or create) the thread from a phone number. ```ts theme={null} export async function main(input) { // Triggered agent: the Conversation Hub fires your HTTP trigger per inbound // message (bursts are debounced per conversation). const conv = conversation.fromTrigger(input); // reads input.metadata.conversationId const { contact, messages, status, humanInLoop } = await conv.get({ messages: 20 }); await conv.sendText(`Oi ${contact.name}!`); } ``` Proactive flows (cron jobs, external events) usually only know the phone number: ```ts theme={null} const conv = await conversation.byPhone('+5511999998888', { connectionId: 'cn_...', // optional — pin a specific WhatsApp number create: true, // create contact (+ conversation) when missing }); conv.snapshot; // resolved state: contact, contactCreated, conversationCreated await conv.sendTemplate({ name: 'followup', language: 'pt_BR' }); ``` `byPhone` matches every stored phone variant (with/without `+`, with/without the Brazilian mobile 9). ### Inbound media Every inbound media message (audio, image, video, document, sticker) reaches the agent **after** the Hub downloads it, with a **presigned URL (6h TTL)** patched into the Meta-shaped envelope — `messages[0].audio.link`, `messages[0].image.link`, and so on. Captions are optional; a bare image triggers the agent the same way. Don't use the accompanying media `id`: it is Meta's single-use media id and the Hub's own download has already consumed it — always read `link`. Voice notes additionally carry the URL as the trigger's `message` body and in `metadata.audioUrl`, ready for transcription. ## Sending messages ```ts theme={null} // Free text — only inside Meta's 24h customer-service window await conv.sendText('Seu pedido saiu para entrega!'); // Outside the window sendText fails with code OUTSIDE_24H_WINDOW — use a template await conv.sendTemplate({ name: 'order_update', language: 'pt_BR' }); // Media: base64, bytes, or a URL (URLs are downloaded inside the agent process) await conv.sendMedia({ mediaKind: 'image', url: 'https://cdn.example.com/boleto.png', caption: 'Segue o boleto' }); // Reply buttons (up to 3) and lists await conv.sendButtons('Confirma o agendamento?', [ { id: 'yes', title: 'Sim' }, { id: 'no', title: 'Não' }, ]); await conv.sendInteractive({ type: 'list', body: 'Escolha um horário', actionTitle: 'Horários', sections: [{ title: 'Amanhã', rows: [{ id: 'h9', title: '09:00' }, { id: 'h14', title: '14:00' }] }], }); ``` ## Interactive messages `sendInteractive` covers every interactive type WhatsApp supports. `button` and `list` are also composable by attendants in the inbox; the rest are agent-only, because they need ids that live outside the portal (published Flows, catalogs, payment configurations). | Type | What it is | Meta's limits | | --------------- | ---------------------------------------------- | ------------------------------------- | | `button` | Up to 3 reply buttons | 3 buttons, title ≤ 20 chars | | `list` | Menu behind a button | 10 sections, 10 rows each | | `cta_url` | Link button — web catalog, order tracker | label ≤ 20 chars, **https** only | | `copy_code` | One-tap copy — PIX codes, coupons | label ≤ 25 chars | | `flow` | Native form (Flow) | CTA ≤ 20 chars | | `carousel` | Up to 10 cards, each a `cta_url` with an image | 10 cards, no header/footer of its own | | `order_details` | Native payment sheet (Native Payments BR) | amounts in cents | | `order_status` | Closes the order a payment sheet opened | quotes the original bubble | A body over Meta's 1024-char limit is **not** rejected: the Hub sends the excess as leading text bubbles and returns their ids in `leadMessageIds`, keeping the tail right above the buttons. You never have to pre-chunk. Every send returns `metaMessageId` — the `wamid` an inbound tap, submission or payment will reference through `context.id`. ```ts theme={null} // Link button and one-tap copy await conv.sendCtaUrl('Veja as opções que separei', { displayText: 'Ver catálogo', url: 'https://loja.exemplo/catalogo' }); await conv.sendCopyCode('*PIX gerado!*', { displayText: 'Copiar código Pix', code: '00020126580014BR.GOV.BCB.PIX...' }); ``` ### Flow (native form) `flowToken` is yours. It travels to Meta untouched and comes back inside the submission (`nfm_reply` → `response_json`), so it is what correlates an answer with the context that asked for it — an order id, a checkout attempt, a profile edit. ```ts theme={null} await conv.sendInteractive({ type: 'flow', body: 'Preenche rapidinho aí 👇', header: 'Cadastro', footer: 'Leva 1 minuto', action: { flowId: '1234567890', // Flow published on the WABA flowToken: `cadastro:${conv.conversationId}`, cta: 'Abrir formulário', screen: 'CADASTRO', // omit for a data_exchange Flow data: { nome: 'Ana', email: 'ana@exemplo.com' }, }, }); ``` `screen` picks the entry point: with it the message navigates to that screen and `data` prefills it; without it the Flow resolves its first screen on your Flow endpoint (`data_exchange`), and sending `data` is an error. Use `mode: 'draft'` to render a Flow that is not published yet. ### Carousel ```ts theme={null} await conv.sendCarousel('Escolhi 3 opções com seu crédito 🎁', [ { imageUrl: 'https://cdn.exemplo/girassol.jpg', body: 'Arranjo Girassol — R$ 269,00', ctaUrl: { displayText: 'Quero esse', url: 'https://loja.exemplo/girassol' }, }, // ...up to 10 ]); ``` Carousels outside a template are not part of Meta's stable Cloud API surface. The Hub sends exactly the documented payload; if your WABA rejects it, the Graph error lands in the message's `failReason` and shows up in the inbox. Smoke-test it on your own number before a flow depends on it. ### Payments (order\_details / order\_status) Every amount is an **integer in cents**, and `totalAmount` has to equal `subtotal + tax + shipping − discount` — the Hub rejects the send with a 400 when it doesn't, which reads far better than Meta's own error. Who owns the charge is a choice — **exactly one** of the two, never both: * `paymentConfiguration` — a payment configuration registered on your WABA; Meta/your PSP generates the charge. * `pixDynamicCode` — an inline PIX charge **your backend** generated (`{ code, merchantName, key, keyType }`, where `code` is the EMV "copia e cola" payload). Use it when the charge id, expiry and reconciliation live on your side. ```ts theme={null} // Variant 1 — WABA-registered configuration (Meta/PSP owns the charge) const order = await conv.sendOrderDetails('Revise e pague', { referenceId: 'PED-1042', paymentConfiguration: 'minha-config-br', items: [{ retailerId: 'SKU-1', name: 'Arranjo Girassol', quantity: 1, amount: 26900 }], subtotal: 26900, shipping: 1500, totalAmount: 28400, }); // Variant 2 — inline PIX generated by your backend await conv.sendOrderDetails('Revise e pague', { referenceId: chargeId, pixDynamicCode: { code: emvPayload, // EMV "copia e cola" from your PSP/backend merchantName: 'Minha Loja', key: 'a1b2c3d4-…', keyType: 'EVP', // CPF | CNPJ | EMAIL | PHONE | EVP }, items: [{ retailerId: chargeId, name: 'Arranjo Girassol', quantity: 1, amount: 26900 }], subtotal: 26900, totalAmount: 26900, }); // Depois da confirmação: sem isso o pedido fica "pendente" para sempre. await conv.sendOrderStatus('Pagamento confirmado!', { referenceId: 'PED-1042', status: 'completed', replyToMetaMessageId: order.metaMessageId!, // a bolha da cobrança }); ``` ### Safe retries (idempotency) Every send accepts an `idempotencyKey`. Retrying with the same key returns the original message instead of double-sending — the reply carries `deduplicated: true`. ```ts theme={null} await conv.sendText('Pedido confirmado!', { idempotencyKey: `${conv.conversationId}:${input.metadata?.messageId ?? 'reply'}`, }); ``` ## Typing indicator & read receipts ```ts theme={null} await conv.typing(); // "typing..." bubble + marks the last inbound message as read await conv.markRead(); // blue ticks only ``` Both anchor on the contact's last inbound message (Meta requires it), so they return **422** on a conversation with no inbound yet. The typing bubble auto-dismisses in \~25s or when your reply arrives — call `typing()` right when the trigger fires, before slow work. ## Handoff & resolution `transfer` opens a ticket in the attendants' queue and flips the conversation out of AI mode. `resolve` closes the case. ```ts theme={null} // Queue of a department (by id or exact name) await conv.transfer({ department: 'Vendas', reason: 'pediu humano', priority: 'HIGH' }); // Straight to a specific attendant (by id or email) — ticket comes pre-assigned await conv.transfer({ attendant: 'paula@empresa.com', reason: 'cliente VIP' }); // Close the conversation — the AI stops processing new inbound messages await conv.resolve('caso encerrado'); ``` ### Choosing where to hand off `conversation.directory` is a read-only view of your workspace's service structure — the **departments** and the **attendants** (human agents) registered in the Conversation Hub. Use it when the agent needs to decide the transfer target at runtime instead of hard-coding a department name. ```ts theme={null} // Which departments exist? const departments = await conversation.directory.departments(); // [{ id, name, color, icon, description, members, agents }] // Who works in Vendas, and how busy are they right now? const team = await conversation.directory.attendants({ department: 'Vendas' }); // [{ id, name, email, status, load, active, ... }] ``` Each attendant carries two live signals: `status` (presence: `online` / `busy` / `away` / `offline`) and `load` (how many human conversations they are handling at this moment). A common pattern — route to the least-busy online attendant, falling back to the department queue when nobody is available: ```ts theme={null} const best = team .filter((a) => a.active && a.status === 'online') .sort((a, b) => a.load - b.load)[0]; await conv.transfer({ attendant: best?.id, // undefined → plain department-queue transfer department: 'Vendas', reason: 'transbordo', }); ``` ### Legacy sync agents Agents invoked through the direct execution endpoint (no HTTP trigger) answer with the `reply` helper — `intent` drives the handoff: ```ts theme={null} import { reply } from '@runflow-ai/sdk/conversation'; return reply('transferindo você para um atendente', { intent: 'escalate' }); // intents: 'escalate' (opens a ticket) · 'resolve' (sends then closes) · 'continue' ``` ## Contacts, tags & notes ```ts theme={null} // Contacts const contact = await conversation.contacts.getByPhone('+5511999998888'); await conversation.contacts.update(contact.id, { handlingArea: 'Financeiro' }); await conversation.contacts.create({ name: 'Maria', phone: '+5511988887777', upsert: true }); // Tag a contact (tags are auto-created on first use) await conversation.contacts.addTag(contact.id, 'vip', '#f59e0b'); await conversation.contacts.removeTag(contact.id, 'inadimplente'); // Tag entity CRUD const tags = await conversation.tags.list(); // [{ id, name, color, contacts }] await conversation.tags.create('vip', '#f59e0b'); await conversation.tags.update(tagId, { name: 'VIP' }); await conversation.tags.remove(tagId); // also unlinks it from every contact // Internal notes — show up in the attendant's context panel await conv.addNote('Cliente pediu boleto até sexta', { agentName: 'Bot Cobrança' }); await conversation.contacts.listNotes(contact.id); await conversation.contacts.removeNote(contact.id, noteId); ``` Agent-authored notes have no human author — the portal shows the `agentName` label (default: `Agente AI`). ### Custom fields (metadata) Contacts carry a flat `metadata` object — key/value pairs for whatever your domain needs (CPF, birth date, plan, consent flags). Both `create` and `update` accept it: ```ts theme={null} import type { ContactMetadata } from '@runflow-ai/sdk/conversation'; await conversation.contacts.create({ name: 'Maria', phone: '+5511988887777', upsert: true, // on an existing contact, metadata is MERGED in — never replaced metadata: { cpf: '12345678900', nascimento: '1990-05-12' }, }); // Partial update: only the keys you send change await conversation.contacts.update(contact.id, { metadata: { plano: 'premium', nascimento: null }, // null removes the key }); const maria = await conversation.contacts.getByPhone('+5511988887777'); maria.metadata; // { cpf: '12345678900', plano: 'premium' } ``` Write semantics — **shallow partial merge**: * A write only touches the keys it mentions; every other key stays as-is. * Sending `null` for a key **removes** it. * On `create` with `upsert: true`, metadata is merged into the existing contact's metadata (it never wipes what's already there). Limits: | Rule | Value | | ------------------- | --------------------------------------------------------------------------- | | Value types | Scalars only — `string`, `number`, `boolean`, `null` (no objects or arrays) | | Key format | `^[a-z0-9_]{1,64}$` | | Keys per contact | 50 max | | String value length | 1,024 chars max | `contact.metadata` comes back on every read that returns a contact — `contacts.getByPhone` and `conv.get().contact`. The value type is exported as `ContactMetadata` from `@runflow-ai/sdk/conversation` (re-exported from the SDK root as `ConversationContactMetadata`). Metadata is **strictly per contact** — there is no shared field schema. A key written on one contact exists only on that contact; the portal renders whatever keys each contact carries, and any key matching the rules above is accepted. ### Metadata in the inbox The fields your agent writes are not just storage — they surface in the attendants' console. Each contact's keys render on their profile and on the inbox side panel, and the inbox can filter conversations by **connection** (which WhatsApp number the thread lives on), by **agent**, and by **custom field** — the attendant types any key and value, and the filter does a case-insensitive partial match on that key's value. Pull up, say, every conversation whose contact has `plano` containing `premium` on the sales number. These filters are a portal feature. The agent API does not expose filtered conversation listing — from agent code you reach conversations through `fromTrigger`, `byId` and `byPhone`. ## Error handling API failures throw `ConversationApiError` with `status`, a typed `code` when available, and the raw `body`: ```ts theme={null} import { ConversationApiError } from '@runflow-ai/sdk/conversation'; try { await conv.sendText('...'); } catch (err) { if (err instanceof ConversationApiError && err.code === 'OUTSIDE_24H_WINDOW') { await conv.sendTemplate({ name: 'reengage' }); } else { throw err; } } ``` Common codes: `OUTSIDE_24H_WINDOW` (free-form send outside Meta's window), `CONTACT_NOT_FOUND` / `CONVERSATION_NOT_FOUND` (`byPhone` without `create`), `CONNECTION_NOT_FOUND` (unknown `connectionId`). ## Status callbacks Meta reports what happened to every message you sent — `delivered`, `read`, `failed`. The Hub always consumes those: the ticks move in the inbox and the message row gets its status either way. Forwarding them **to the agent** is opt-in, because each callback becomes one agent execution — a 1,000-message campaign can mean a few thousand runs. Turn it on only for flows that actually track delivery (a reminder that escalates when it goes unread, for instance). In the Runflow portal: **Agents → configure the agent → Encaminhar entregas/leituras**, where each agent inherits the organization default or overrides it. The organization-wide value lives in **Settings → Recursos** (admins only). When it is on, the agent is triggered with an empty message, `metadata.kind = 'status'` and the raw Meta status payload — the same wamid your send returned as `metaMessageId`. ## API surface | Handle (`conv`) | Hub (`conversation`) | | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `get({ messages? })` | `fromTrigger(input)` · `byId(id)` · `byPhone(phone, opts?)` | | `sendText` · `sendTemplate` · `sendMedia` · `sendButtons` · `sendInteractive` | `contacts.*` — create, getByPhone, update (create/update accept `metadata`), addTag, removeTag, addNote, listNotes, removeNote | | `sendCtaUrl` · `sendCopyCode` · `sendFlow` · `sendCarousel` · `sendOrderDetails` · `sendOrderStatus` | | | `typing()` · `markRead()` | `tags.*` — list, create, update, remove | | `transfer(opts?)` · `resolve(reason?)` | `directory.departments()` · `directory.attendants({ department? })` | | `addNote(text, { agentName? })` | `reply(message, { intent?, metadata? })` (sync path) | # Cross-Agent SDK Source: https://docs.runflow.ai/core-concepts/cross-agent Invoke other agents, read their executions, walk their threads, write reviews, and manage their memory — all tenant-scoped The **Cross-Agent SDK** lets one agent operate on another agent's data within the same tenant. Read executions, walk conversation threads, write reviews against an agent's work, and curate its memory — all from inside agent code. Without these primitives, `client.chat()` always targets the *current* agent. The cross-agent surface closes that gap and unlocks three common patterns: * **Reviewer agents** — judge another agent's recent executions and write reviews automatically * **Follow-up agents** — find conversations idle for >24h and ping the original agent to re-engage * **Metrics / curator agents** — emit custom domain events from another agent's logs, or curate its long-term memory All operations are tenant-scoped via the runtime API key. Cross-tenant references return **404** (not 403) so existence is never leaked across tenants. ## The four modules Invoke other agents (`invoke` / `invokeAsync`), list, get. Read execution rows and the hierarchical trace tree, with pagination. Walk grouped conversations by entity (phone, email, contact). `getFullThread` returns thread + executions + traces in one call. Cross-agent memory: get / set / append / clear / search / list / summarize on another agent's slots. ## Universal agent reference Every cross-agent endpoint accepts **the same three identifier forms** for the target agent. Resolution is server-side; the database always sees the canonical UUID. Keyed on `(id, tenantId, ACTIVE)`. Single-match. Exact match on `(tenantId, slug, ACTIVE)`. Slugs are URL-friendly identifiers, unique per tenant. Auto-generated from the agent name on creation when not set explicitly. Must be unambiguous. If two active agents share the name, the call returns **409** with a "use UUID or assign each a unique slug" hint. ```typescript theme={null} // All three work — pick whatever reads best at the call site: await agents.invoke('a1b2c3d4-...', { message: 'oi' }); // UUID await agents.invoke('customer-support', { message: 'oi' }); // slug await agents.invoke('Customer Support', { message: 'oi' }); // name ``` This applies to `Agents.*`, `Reviews.{create,list,stats,exportForTraining}`, `Executions.list({ agentId })`, `Threads.list({ agentId })`, and `MemoryAdmin.*`. The slug field on the agent is optional in the create modal. Leave it blank and the backend derives one from the name (`Customer Support Bot` → `customer-support-bot`). Manual edits make the slug "sticky" — renaming the agent won't overwrite a slug you customized. ## Agents Cross-agent invocation and discovery. ```typescript theme={null} import { Agents } from '@runflow-ai/sdk/agents'; const agents = new Agents(); // Sync — DEFAULT. Wait for the target agent to finish (timeout 60s default). const result = await agents.invoke('customer-support', { message: 'Resumo das últimas 24h', userId: 'reviewer-agent', channel: 'review', }); console.log(result.output); // Async — fire and forget; backend returns the executionId immediately. const { executionId } = await agents.invokeAsync('customer-support', { message: 'Olá! Notei que estamos sem falar há 24h. Tudo bem?', userId: '+5511999999999', channel: 'follow-up', }); // Discovery await agents.list({ limit: 50 }); await agents.get('customer-support'); ``` ### Sync vs async — when to pick which | Use case | Method | | ---------------------------------------------- | --------------- | | Reviewer / metrics / eval agent (needs output) | `invoke()` | | Follow-up agent (ping and forget) | `invokeAsync()` | | Fire side-effect (audit log, webhook) | `invokeAsync()` | | RAG/judge chain that needs result | `invoke()` | ### Flexible input — anything goes to `request.*` The entire input object is forwarded to the Go executor and exposed under `request.*` in the target's handler. `message` is **not required** — pass arbitrary structured payloads. ```typescript theme={null} await agents.invoke('order-processor', { orderId: 'ord-123', action: 'fulfill', items: [{ sku: 'A', qty: 2 }], metadata: { source: 'reviewer' }, }); // Inside order-processor: export async function main(input) { const { orderId, action, items } = input.request; // ... } ``` ## Executions Read execution rows across agents in the caller's tenant. ```typescript theme={null} import { Executions } from '@runflow-ai/sdk/executions'; const executions = new Executions(); const { data } = await executions.list({ agentId: 'customer-support', limit: 100 }); for (const exec of data) { const detail = await executions.get(exec.id); // detail.input, detail.output, detail.duration, detail.cost, ... } ``` ### `getDetails` — execution + full trace tree Returns the execution row plus the hierarchical trace tree (LLM calls, tool calls, sub-spans). What you see on the "execution detail" page in the portal. ```typescript theme={null} const { execution, traces, tracesTotal, tracesHasMore } = await executions.getDetails(executionId); walkTrace(traces); // each trace node has .children ``` ### Trace pagination — protect the DB A pathological execution (deep workflow, tool loop, RAG-heavy turn) can produce thousands of traces. Before pagination, one bad execution could pin Postgres and return a multi-megabyte payload. | Caller | Mode | Per-request limit | Why | | ----------- | --------- | ---------------------- | ------------------------------------------------------------------------------------------------- | | Portal | Bulk | 10 000 (hard cap) | UI renders aggregations from the trace array. Cap is a safety net — normal traffic never hits it. | | SDK runtime | Paginated | 500 default, 1 000 max | Forces SDK consumers to walk pages instead of pulling everything. | ```typescript theme={null} // Page 1 const page1 = await executions.getDetails(execId, { traceLimit: 500, traceOffset: 0 }); if (page1.tracesHasMore) { // Page 2 const page2 = await executions.getDetails(execId, { traceLimit: 500, traceOffset: 500 }); } ``` ### `iterateTraces` — walk every page without boilerplate Async generator that walks pages until `tracesHasMore` is false. Yields one page at a time so memory doesn't spike on huge executions. ```typescript theme={null} for await (const page of executions.iterateTraces(execId, { pageSize: 500 })) { console.log(`offset=${page.tracesOffset} / total=${page.tracesTotal}`); for (const root of page.traces) audit(root); } ``` `iterateTraces` has a hard safety cap of 100 pages (\~100k traces). If you hit it, something is wrong upstream — investigate the agent, don't crank the limit. ## Threads Threads are **grouped executions** by entity (phone, email, contact). One conversation that spans multiple executions over time = one thread. ```typescript theme={null} import { Threads } from '@runflow-ai/sdk/threads'; const threads = new Threads(); // Threads idle for >24h, ordered by last activity const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); const { threads: list } = await threads.list({ agentId: 'customer-support', dateTo: cutoff, limit: 50, }); // Drill into one conversation const { executions: timeline } = await threads.getExecutions(list[0].thread_id, { limit: 50, order: 'asc', }); ``` ### Granularity map ``` Thread conversation between user Y and agent X (1 per entity) └─ Execution one turn in that conversation (1 per user message) └─ Trace step inside that turn (many per execution) ├─ agent_execution ├─ llm_call (GPT/Claude call) ├─ tool_call (knowledge_search, connector, ...) └─ tool_call (etc) ``` ### `getFullThread` — thread + executions + traces in one call ```typescript theme={null} const full = await threads.getFullThread(threadId, { agentId: 'customer-support', maxTraces: 20, // cap on executions to deep-fetch (default 20) traceLimit: 500, // per-execution trace page size }); // full.threadId // full.total ← total executions // full.executions[].execution ← row (input/output/cost/duration) // full.executions[].traces[] ← trace tree // full.executions[].traces[].children[] ← sub-spans ``` Fetches in two stages: list executions, then `executions.getDetails` for each (concurrency capped at 5). Individual failures are silently dropped so a single bad execution doesn't break the whole batch. ## Memory Admin The default `Memory` module is scoped to the *caller's* agent. To curate another agent's memory (audit messages, inject system context, clear stale sessions, summarize), use `MemoryAdmin`. ```typescript theme={null} import { MemoryAdmin } from '@runflow-ai/sdk/memory-admin'; const admin = new MemoryAdmin(); // Inventory — list every memory slot owned by the target agent const { sessions } = await admin.list('customer-support'); // Filter by activity window const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); const recent = await admin.list('customer-support', { dateFrom: since, dateField: 'updated_at', limit: 50, }); // Read another agent's memory const data = await admin.get('customer-support', 'phone:+5511999999999'); // Inject a system message (curator agent) await admin.append('customer-support', 'phone:+5511999999999', { role: 'system', content: 'IMPORTANTE: cliente prioritário, responder em <2min.', }); // Search within a time window const hits = await admin.search( 'customer-support', 'phone:+5511999999999', 'erro', { dateFrom: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), limit: 100, }, ); // Summarize the slot — generates an LLM summary and persists it const { summary } = await admin.summarize( 'customer-support', 'phone:+5511999999999', { prompt: 'Resume em até 5 bullets, em português:' }, ); // Clear (deletes the session) await admin.clear('customer-support', 'phone:+5511999999999'); ``` Memory keys are prefixed with the **target agent's** id (not the caller's), so the existing data isolation model stays intact. Each agent has its own namespace; MemoryAdmin just flips which namespace you target. ## Reviews — full lifecycle The existing `Reviews` module covers production execution reviews. Two new ergonomic helpers for the common verdict transitions: ```typescript theme={null} import { Reviews } from '@runflow-ai/sdk/reviews'; const reviews = new Reviews(); // Create (auto-judge case) await reviews.create({ executionId, agentId: 'customer-support', // UUID, slug, or name rating: 'bad', comment: 'Bot deu horário de funcionamento errado.', priority: 'high', tags: ['hours-wrong'], }); // List, filter, stats — accept slug/name too await reviews.list({ agentId: 'customer-support', status: 'pending_review', limit: 100 }); await reviews.stats({ agentId: 'customer-support' }); // NEW — resolve a review (= update with status='resolved') await reviews.resolve(reviewId, { actionTaken: 'knowledge_base_updated', correctedOutput: 'O horário correto é 9h–18h, seg-sex.', resolutionNotes: 'Adicionei o doc faltante no vector store.', }); // NEW — dismiss a review (= update with status='wont_fix') await reviews.dismiss(reviewId, { resolutionNotes: 'edge case, ignoring' }); ``` Reviews stamped by SDK callers show up in the UI as `reviewedBy: apikey:` — easy to filter from human reviews. ## Recipes ### Reviewer agent — automated quality control ```typescript theme={null} const recent = await executions.list({ agentId: TARGET, limit: 100 }); for (const exec of recent.data) { const { exists } = await reviews.checkHasReview(exec.id); if (exists) continue; const detail = await executions.get(exec.id); const verdict = await runJudge(detail); // your LLM judge await reviews.create({ executionId: exec.id, agentId: TARGET, rating: verdict.rating, comment: verdict.reason, tags: ['auto-judge'], }); } ``` Wire it to a daily CRON trigger and humans only see the bad reviews. See [Auto-reviewer agent](/use-cases/auto-reviewer-agent) for the full walkthrough. ### Follow-up agent — decoupled from the conversation flow ```typescript theme={null} const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); const { threads: stale } = await threads.list({ agentId: TARGET, dateTo: cutoff, limit: 50, }); for (const t of stale) { await agents.invokeAsync(TARGET, { message: 'Olá! Notei que estamos sem falar há 24h. Tudo bem?', userId: t.entity_value, channel: 'follow-up', metadata: { followUpFor: t.thread_id, reason: 'idle-24h' }, }); } ``` The follow-up agent is just a normal agent with its own cron trigger — none of the logic leaks into the original conversation. ### Metrics agent — custom KPIs from execution logs ```typescript theme={null} import { track } from '@runflow-ai/sdk/observability'; const recent = await executions.list({ agentId: TARGET, limit: 500 }); for (const exec of recent.data) { const detail = await executions.get(exec.id); if (looksLikeBooking(detail.output)) { track('booking_completed', { agentId: TARGET, threadId: detail.threadId, durationMs: detail.duration, cost: detail.cost, }); } } ``` Dashboards (or external BI) get a real `booking_completed` event without touching the target agent at all. ## Security model All cross-agent endpoints sit behind `RuntimeAuthGuard` or `SdkAuthGuard` and resolve `tenantId` from the credential — never from the body or query. | Endpoint | How the tenant is enforced | | ------------------------------------------ | -------------------------------------------------------------------------------------------- | | `POST /runtime/agents/:ref/invoke[-async]` | Agent lookup keyed on `(id\|slug\|name, tenantId, ACTIVE)`. Ambiguous name → 409, miss → 404 | | `GET /runtime/v1/observability/threads` | Service-layer `WHERE tenant_id = $1` in raw SQL | | `GET .../threads/:id/executions` | Same tenant filter at service layer | | `GET .../executions/:id/details` | Controller-side `tenantId !== sdkContext.tenantId` → 404 | | `POST .../executions/:id/reviews` | `req.sdkContext.tenantId` carried into the service | | `/runtime/v1/agents/:ref/memory/*` | Resolves target via the same agent gate — memory keys prefixed with the **target's** id | Cross-tenant access returns **404** (not 403) — existence is never leaked. ## SDK version requirement `@runflow-ai/sdk >= 1.2.0`. Older versions don't have the new namespaces on the API client and throw a clear "namespace missing" error at construction time. ## Next steps Full walkthrough of an automated quality-control agent. Single-agent memory module (the default). Tracing and business events. All standalone SDK exports. # Environments Source: https://docs.runflow.ai/core-concepts/environments Deploy safely with staging and production environments Runflow gives every tenant two environments: **staging** and **production**. You edit, deploy, and test in staging, then publish to production when you're ready. This keeps real users insulated from work-in-progress changes. ## How It Works * **Staging** is where all edits happen: agent deploys, draft prompts, connector changes, and new triggers. * **Production** is read-only in the dashboard. It only changes when you explicitly publish something from staging. * Each environment has its own endpoint URL, executions, sessions, traces, and memory namespace — they never mix. There's nothing to set up. Every agent, connector, prompt, and trigger you create is already environment-aware. ## CLI Workflow ### Deploy to Staging `rf agents deploy` always targets **staging**: ```bash theme={null} cd my-agent/ rf agents deploy ``` This pushes your local changes and makes the new version live at the staging endpoint. Production is not affected. ### Promote to Production When staging looks good, promote it to production: ```bash theme={null} rf agents promote ``` You'll get an interactive confirmation. After it runs, the production endpoint serves the same code that was in staging. `rf agents promote` only promotes the **agent code**. Prompts, connectors, and triggers are published separately from the dashboard — see the Platform section below. ### Local Testing with `rf test` `rf test` runs your agent locally **in staging mode**: ```bash theme={null} cd my-agent/ rf test ``` That means, while testing locally: * Prompts resolve to their **draft** (staging) versions * Connectors resolve to their **staging** instances and resources * Memory is written under a `staging:` namespace * Traces are tagged as staging Because `rf test` is hardwired to staging, your local iterations never touch production state. You can run freely without worrying about polluting real user data. ## Platform (Dashboard) Workflow ### Environment Switcher The dashboard header shows the current environment with a colored badge: **amber for Staging**, **emerald for Production**. Click it to switch. * In **Staging**, every screen is editable. * In **Production**, every screen is read-only. A banner on each screen explains how to switch back to staging to make changes. ### Publishing Changes to Production When you're ready to promote changes, open the **Releases** screen. It lists everything that's different between staging and production: * **Agents** with unpromoted staging code (with commit history and file diffs) * **Prompts** with draft versions (side-by-side diff of the prompt text) * **Connectors** with added, removed, or modified resources You can publish items one at a time or publish everything in one action. Review the diffs carefully before publishing — production changes take effect immediately for all users. ## What Is Separated by Environment | Entity | Per-environment? | Notes | | ----------------------- | :--------------: | --------------------------------------------------------------------------------------------- | | **Agents** | Yes | Separate code and endpoint URL per environment. Agent metadata (name, description) is shared. | | **Connector instances** | Yes | Same connector slug can exist in both environments with different configs and resources. | | **Triggers** | Yes | Each trigger targets one environment. Duplicate them if you need the same behavior in both. | | **Prompts** | Yes | Draft versions are staging. Published versions are production. | | **Executions** | Yes | Tagged at runtime. Filter by environment in the dashboard. | | **Sessions / Threads** | Yes | Filtered by environment everywhere they're listed. | | **Memory** | Yes | Staging uses its own `staging:` namespace. | | **Traces** | Yes | Tagged with environment so you can filter observability data. | | **Credentials** | No | Shared across both environments. Create once, use anywhere. | | **Knowledge Base** | No | Vector stores are shared. The same KB serves staging and production agents. | | **LLM Providers** | No | Shared. | | **Data Sources** | No | Shared. | ## Best Practices * **Iterate in staging first.** Use `rf test` for the local loop, then `rf agents deploy` to validate on the staging endpoint before promoting. * **Publish related changes together.** If an agent deploy depends on a new prompt version, review them both on the Releases screen and publish them in the same batch. * **Handle shared credentials carefully.** Because credentials are shared across environments, rotating or changing one affects production immediately. Plan credential changes during low-traffic windows. * **Don't rely on production memory for testing.** Since memory is namespaced per environment, staging agents start with a clean slate. ## FAQ ### Why didn't my prompt change show up in production? Prompt changes are published separately from agent deploys. Open the Releases screen — your prompt is likely a draft waiting to be published. ### Do triggers fire in both environments? No. Each trigger is bound to one environment. Create a separate trigger per environment if you want the same behavior on both. ### Can a staging run talk to the same external APIs as production? Yes. Credentials, knowledge bases, and data sources are shared, so staging agents hit the same external services and KBs as production. Only agent state (memory, sessions, executions, traces) is isolated. ### What environment does `rf test` use? Staging, always. This is intentional — it keeps local testing from leaking into real user data. ## Next Steps Deploy and promote agents from the CLI Inspect traces and executions per environment Configure per-environment integrations Manage prompt drafts and versions # File System Source: https://docs.runflow.ai/core-concepts/file-system Read and write files during agent execution in the sandboxed environment During execution, your agent runs inside an **isolated sandbox** with a dedicated filesystem. You can read and write files using Node.js `fs` module — no SDK wrapper needed. ## How It Works Each agent execution gets its own temporary workspace. The only writable directory is `/work` — this is your working directory, HOME, and CWD. All other paths in the sandbox are read-only for security. Everything in `/work` is **ephemeral** — destroyed after execution ends. If you need to persist data, send it to an external API before the execution finishes. ## Writing Files ```typescript theme={null} import { createTool } from '@runflow-ai/sdk'; import { writeFile } from 'fs/promises'; import { z } from 'zod'; const generateReport = createTool({ id: 'generate-report', description: 'Generate a CSV report', inputSchema: z.object({ data: z.array(z.record(z.string())), filename: z.string(), }), execute: async (params) => { const headers = Object.keys(params.data[0]).join(','); const rows = params.data.map(row => Object.values(row).join(',')); const csv = [headers, ...rows].join('\n'); const path = `/work/${params.filename}`; await writeFile(path, csv, 'utf-8'); return { success: true, path, rows: params.data.length }; }, }); ``` ## Reading Files ```typescript theme={null} import { createTool } from '@runflow-ai/sdk'; import { readFile } from 'fs/promises'; import { z } from 'zod'; const readConfig = createTool({ id: 'read-config', description: 'Read a configuration file', inputSchema: z.object({ filename: z.string(), }), execute: async (params) => { const content = await readFile(`/work/${params.filename}`, 'utf-8'); return { content }; }, }); ``` ## Persisting Files Files in `/work` are deleted when execution ends. To keep them, send to an external service before finishing: ### Send to External API ```typescript theme={null} import { httpPost } from '@runflow-ai/sdk/http'; import { readFile, writeFile } from 'fs/promises'; await writeFile('/work/output.pdf', pdfBuffer); const fileContent = await readFile('/work/output.pdf'); await httpPost('https://api.example.com/upload', fileContent, { headers: { 'Content-Type': 'application/pdf' }, }); ``` ## Working with Binary Files ```typescript theme={null} import { writeFile, readFile } from 'fs/promises'; import { httpGet } from '@runflow-ai/sdk/http'; // Download a file const imageBuffer = await httpGet('https://example.com/image.png', { responseType: 'arraybuffer', }); await writeFile('/work/image.png', Buffer.from(imageBuffer)); // Process it const data = await readFile('/work/image.png'); // ... process the binary data ``` ## Temporary Files in Workflows Workflows can pass file paths between steps: ```typescript theme={null} import { flow } from '@runflow-ai/sdk'; import { writeFile, readFile } from 'fs/promises'; const pipeline = flow('data-pipeline') .step('fetch', async ({ input }) => { const data = await fetchData(input.source); const path = '/work/raw-data.json'; await writeFile(path, JSON.stringify(data)); return { path }; }) .step('transform', async ({ results }) => { const raw = await readFile(results.fetch.path, 'utf-8'); const data = JSON.parse(raw); const transformed = data.map(transform); const path = '/work/transformed.json'; await writeFile(path, JSON.stringify(transformed)); return { path, count: transformed.length }; }) .step('upload', async ({ results }) => { const content = await readFile(results.transform.path, 'utf-8'); await httpPost('https://api.example.com/data', JSON.parse(content), { headers: { 'Authorization': `Bearer ${process.env.API_TOKEN}` }, }); return { uploaded: true }; }) .build(); ``` ## Security and Limits The sandbox is fully isolated — each execution runs in its own environment with strict constraints: | Constraint | Value | | ------------------ | -------------------------------------------------- | | Writable directory | `/work` only | | Everything else | Read-only (agent code, system binaries, libraries) | | Process user | Unprivileged (no root access) | | Cleanup | All files deleted after execution | **Never store secrets in files.** Use environment variables or the Credentials module instead. Files in `/work` are accessible to all code running in the same execution. The `/work` directory has `0777` permissions — your agent code can create, read, and write files freely within it. Trying to write anywhere else will fail with a permission error. ## Available System Tools The sandbox includes common utilities you can use via `child_process`: * **ffmpeg** / **ffprobe** — audio/video processing * **python3** — Python scripts * **node** — Node.js * **imagemagick** (`convert`) — image manipulation ```typescript theme={null} import { execSync } from 'child_process'; // Convert audio format execSync('ffmpeg -i /work/input.ogg -ar 16000 /work/output.wav'); // Run a Python script execSync('python3 /work/script.py'); ``` ## Next Steps Build tools that work with files Download and upload files via HTTP Upload files to knowledge bases Process audio and images # HTTP Utilities Source: https://docs.runflow.ai/core-concepts/http-utilities Pre-configured HTTP utilities for making API requests The **HTTP** module provides pre-configured utilities for making HTTP requests in tools and agents. Built on top of **axios**, it comes with sensible defaults, automatic error handling, and full TypeScript support. ## Features * 🌐 **Pre-configured axios instance** with 30s timeout * 🛡️ **Automatic error handling** with enhanced error messages * 🎯 **Helper functions** for common HTTP methods (GET, POST, PUT, PATCH, DELETE) * 📦 **Zero configuration** - works out of the box * 🔒 **Type-safe** - Full TypeScript support with exported types * ⚡ **Available in all agents** - No need to install additional dependencies ## Quick Start ```typescript theme={null} import { createTool } from '@runflow-ai/sdk'; import { http, httpGet, httpPost } from '@runflow-ai/sdk/http'; import { z } from 'zod'; const weatherTool = createTool({ id: 'get-weather', description: 'Get current weather for a city', inputSchema: z.object({ city: z.string(), }), execute: async (params) => { try { // Option 1: Using httpGet helper (simplest) const data = await httpGet('https://api.openweathermap.org/data/2.5/weather', { params: { q: params.city, appid: process.env.OPENWEATHER_API_KEY, units: 'metric', }, }); return { city: data.name, temperature: data.main.temp, condition: data.weather[0].description, }; } catch (error: any) { return { error: `Failed to fetch weather: ${error.message}` }; } }, }); ``` ## Helper Functions The SDK provides convenient helper functions that automatically extract data from responses: ```typescript theme={null} import { httpGet, httpPost, httpPut, httpPatch, httpDelete } from '@runflow-ai/sdk/http'; // GET request - returns only the data payload const user = await httpGet('https://api.example.com/users/123'); console.log(user.name); // POST request const newUser = await httpPost('https://api.example.com/users', { name: 'John Doe', email: 'john@example.com', }); // PUT request const updated = await httpPut('https://api.example.com/users/123', { name: 'Jane Doe', }); // PATCH request const patched = await httpPatch('https://api.example.com/users/123', { email: 'newemail@example.com', }); // DELETE request await httpDelete('https://api.example.com/users/123'); ``` ## Using the HTTP Instance For more control, use the pre-configured `http` instance directly: ```typescript theme={null} import { http } from '@runflow-ai/sdk/http'; // GET with full response const response = await http.get('https://api.example.com/data'); console.log(response.status); console.log(response.headers); console.log(response.data); // POST with custom headers const response = await http.post( 'https://api.example.com/resource', { data: 'value' }, { headers: { 'Authorization': `Bearer ${process.env.API_TOKEN}`, 'Content-Type': 'application/json', }, timeout: 5000, } ); // Multiple requests in parallel const [users, posts, comments] = await Promise.all([ http.get('https://api.example.com/users'), http.get('https://api.example.com/posts'), http.get('https://api.example.com/comments'), ]); ``` ## Error Handling All HTTP utilities provide enhanced error messages: ```typescript theme={null} import { httpGet } from '@runflow-ai/sdk/http'; try { const data = await httpGet('https://api.example.com/data'); return { success: true, data }; } catch (error: any) { // Error message includes HTTP status and details console.error(error.message); // "HTTP GET failed: HTTP 404: Not Found" return { success: false, error: error.message }; } ``` ## Complete Example: Weather Tool ```typescript theme={null} import { Agent, openai, createTool } from '@runflow-ai/sdk'; import { httpGet } from '@runflow-ai/sdk/http'; import { z } from 'zod'; const weatherTool = createTool({ id: 'get-weather', description: 'Get current weather for any city', inputSchema: z.object({ city: z.string().describe('City name (e.g., "São Paulo", "New York")'), }), execute: async (params) => { try { const apiKey = process.env.OPENWEATHER_API_KEY; const data = await httpGet('https://api.openweathermap.org/data/2.5/weather', { params: { q: params.city, appid: apiKey, units: 'metric', lang: 'pt_br', }, timeout: 5000, }); return { city: data.name, temperature: data.main.temp, feelsLike: data.main.feels_like, condition: data.weather[0].description, humidity: data.main.humidity, windSpeed: data.wind.speed, }; } catch (error: any) { if (error.message.includes('404')) { return { error: `City "${params.city}" not found` }; } throw new Error(`Weather API error: ${error.message}`); } }, }); const agent = new Agent({ name: 'Weather Assistant', instructions: 'You help users check the weather. Use the weather tool when users ask about weather conditions.', model: openai('gpt-4o'), tools: { weather: weatherTool, }, }); // Use the agent const result = await agent.process({ message: 'What is the weather like in São Paulo?', }); ``` ## Next Steps Use built-in connectors Learn more about tools # Knowledge (RAG) Source: https://docs.runflow.ai/core-concepts/knowledge-rag Semantic search in vector knowledge bases The **Knowledge** module (also called RAG) manages semantic search in vector knowledge bases. ## Standalone Knowledge Manager ```typescript theme={null} import { Knowledge } from '@runflow-ai/sdk'; const knowledge = new Knowledge({ vectorStore: 'support-docs', k: 5, threshold: 0.7, }); // Basic search const results = await knowledge.search('How to reset password?'); results.forEach(result => { console.log(result.content); console.log('Score:', result.score); }); // Get formatted context for LLM const context = await knowledge.getContext('password reset', { k: 3 }); console.log(context); ``` ## Agentic RAG in Agent When RAG is configured in an agent, the SDK automatically creates a `searchKnowledge` tool that the LLM can decide when to use. This is more efficient than always searching, as the LLM only searches when necessary. ```typescript theme={null} const agent = new Agent({ name: 'Support Agent', instructions: 'You are a helpful support agent.', model: openai('gpt-4o'), rag: { vectorStore: 'support-docs', k: 5, threshold: 0.7, // Custom search prompt - guides when to search searchPrompt: `Use searchKnowledge tool when user asks about: - Technical problems - Process questions - Specific information Don't use for greetings or casual chat.`, toolDescription: 'Search in support documentation for solutions', }, }); // Agent automatically has 'searchKnowledge' tool // LLM decides when to search (not always - more efficient!) const result = await agent.process({ message: 'How do I reset my password?', }); ``` ## Multiple Vector Stores ```typescript theme={null} const agent = new Agent({ name: 'Advanced Support Agent', instructions: 'Help users with multiple knowledge bases.', model: openai('gpt-4o'), rag: { vectorStores: [ { id: 'support-docs', name: 'Support Documentation', description: 'General support articles', threshold: 0.7, k: 5, searchPrompt: 'Use search_support-docs when user has technical problems or questions', }, { id: 'api-docs', name: 'API Documentation', description: 'Technical API reference', threshold: 0.8, k: 3, searchPrompt: 'Use search_api-docs when user asks about API endpoints or integration', }, ], }, }); ``` ## Managing Documents Add text documents: ```typescript theme={null} import { Knowledge } from '@runflow-ai/sdk'; const knowledge = new Knowledge({ vectorStore: 'support-docs', }); // Add a text document const result = await knowledge.addDocument( 'How to reset password: Go to settings > security > reset password', { title: 'Password Reset Guide', category: 'authentication', version: '1.0' } ); console.log('Document added:', result.documentId); ``` Upload files: ```typescript theme={null} import * as fs from 'fs'; // Node.js - Upload from file system const fileBuffer = fs.readFileSync('./manual.pdf'); const result = await knowledge.addFile( fileBuffer, 'manual.pdf', { title: 'User Manual', mimeType: 'application/pdf', metadata: { department: 'Support', version: '2.0' } } ); ``` ## Async Ingestion for Large Files Available since SDK `1.3.2` (platform July 2026). For small files `addFile` still works — async ingestion is the recommended path for anything big. `addFile` processes the file synchronously — fine for a manual or an FAQ, but a 50k-row catalog would hold the HTTP request open for minutes. `ingestFile` uploads the file, returns in seconds with a job id, and the platform embeds everything in the background with batched embeddings and checkpoint resume (if a worker restarts mid-job, ingestion continues from where it stopped instead of starting over). ```typescript theme={null} import * as fs from 'fs'; import { Knowledge } from '@runflow-ai/sdk'; const knowledge = new Knowledge({ vectorStore: 'product-catalog' }); // Fire-and-forget: returns as soon as the job is accepted (HTTP 202) const { jobId } = await knowledge.ingestFile( fs.readFileSync('./catalog.csv'), 'catalog.csv', { mimeType: 'text/csv' } ); // Poll whenever you want const job = await knowledge.getIngestionJob(jobId); console.log(job.status, `${job.processedChunks}/${job.totalChunks}`); ``` Or block until it finishes: ```typescript theme={null} const result = await knowledge.ingestFile( fs.readFileSync('./catalog.csv'), 'catalog.csv', { mimeType: 'text/csv', waitForCompletion: true, onProgress: (job) => console.log(`${Math.round(job.progress * 100)}%`), } ); console.log('Indexed', result.job?.processedChunks, 'chunks'); ``` ### CSV: one document per row CSV files are ingested **one document per row** — ideal for product catalogs and structured data. Each row becomes a searchable `Column: value` document. You can control which columns are embedded and which go to metadata: ```typescript theme={null} await knowledge.ingestFile(fs.readFileSync('./catalog.csv'), 'catalog.csv', { mimeType: 'text/csv', csv: { delimiter: ';', // sniffed automatically when omitted contentColumns: ['name', 'brand'], // embedded (default: all columns) metadataColumns: ['sku', 'price'], // copied to each document's metadata }, }); ``` ### Data hygiene Optional cleanup applied server-side before embedding — useful when the source data carries HTML, URLs, or placeholder values that hurt search quality: ```typescript theme={null} await knowledge.ingestFile(file, 'export.csv', { hygiene: { stripHtml: true, // strip tags, decode entities removeUrls: true, // drop URLs from content dropEmptyValues: true, // remove "-", "n/a", "..." placeholder fields normalizeWhitespace: true, // collapse repeated spaces/newlines dedupeUnits: true, // drop duplicate rows/chunks }, }); ``` ### Job status `getIngestionJob(jobId)` (and the `onProgress` callback) return: | Field | Description | | --------------------------------- | -------------------------------------------------------------- | | `status` | `queued` → `processing` → `completed` \| `failed` | | `progress` | Completion ratio `0..1` (0 while the worker is still planning) | | `processedChunks` / `totalChunks` | Embedded units vs. total planned | | `documentId` | Set when `completed` — the parent document id | | `error` | Set when `failed` | `waitForCompletion` throws if the job fails or the timeout (default 30 min) elapses — on timeout the job keeps running server-side and you can keep polling. ## Metadata Filters Filter search results by document metadata using the `filters` option. Each key maps to a metadata field. **Simple equality filter:** ```typescript theme={null} const results = await knowledge.search('reset password', { k: 5, filters: { category: 'authentication', language: 'en', }, }); ``` **Custom operators (JSONB):** Pass an object with `value` and `operator` for non-equality comparisons: ```typescript theme={null} const results = await knowledge.search('pricing plans', { k: 10, filters: { version: { value: "2.0", operator: '>=' }, status: 'published', }, }); ``` Supported operators: `=` (default), `!=`, `>`, `>=`, `<`, `<=`, `@>` (contains), `<@` (contained by). Filters also work in agent RAG config: ```typescript theme={null} const agent = new Agent({ name: 'Support Agent', model: openai('gpt-4o'), rag: { vectorStore: 'support-docs', k: 5, threshold: 0.7, filters: { department: 'support', status: 'published', }, }, }); ``` ## RAG Interceptor & Rerank **Interceptor - Filter & Transform Results:** ```typescript theme={null} const agent = new Agent({ name: 'Smart Agent', model: openai('gpt-4o'), rag: { vectorStore: 'docs', k: 10, // Interceptor: Customize results before LLM onResultsFound: async (results, query) => { // Filter sensitive data const filtered = results.filter(r => !r.metadata?.internal); // Enrich with external data const enriched = await Promise.all( filtered.map(async r => ({ ...r, content: `${r.content}\n\nSource: ${r.metadata?.url}`, })) ); return enriched; }, }, }); ``` **Rerank Strategies:** * `reciprocal-rank-fusion` - Standard RRF algorithm * `score-boost` - Boost results containing keywords * `metadata-weight` - Weight by metadata field value * `custom` - Custom scoring function ## Next Steps Learn about agents See RAG examples # KV Store Source: https://docs.runflow.ai/core-concepts/kv-store Persistent key-value storage with TTL, namespaces and pattern search The **KV Store** is a persistent key-value database built into the platform. Use it to share state across executions, sessions and agents — shopping carts, feature flags, counters, idempotency keys — without standing up your own Redis or database. * **Tenant-scoped** — all agents in your workspace read and write the same store * **Namespaces** — created implicitly on first write, no setup required * **TTL** — optional per-key expiration in seconds * **Pattern search** — find keys with glob patterns like `cart:*:items` * **Any JSON value** — objects, arrays, strings, numbers, booleans (up to 256 KB per entry) ## KV Store vs Memory The KV Store holds **business state**; [Memory](/core-concepts/memory) holds **conversation context**. A value you `set()` comes back exactly as stored, for as long as you need it — while Memory content is trimmed and summarized as the conversation grows. | Need | Use | | --------------------------------------------------------------------- | ------------ | | Cart contents, feature flags, counters, preferences, idempotency keys | **KV Store** | | Message history, conversation summaries, session status, follow-ups | **Memory** | If you were persisting data by stuffing it into sessions or message history, the KV Store replaces that workaround. See [Abandoned Cart Recovery](/use-cases/abandoned-cart-recovery) for both working together — cart in KV, dialogue in Memory. ## Quick Start ```typescript theme={null} import { KV } from '@runflow-ai/sdk'; // Namespace dedicated to a domain (created automatically on first set) const carts = KV.namespace('carts'); // Set a value with a 1-hour TTL await carts.set('cart:5511999:items', { items: ['sku-1', 'sku-2'] }, { ttl: 3600 }); // Read it back (null when missing or expired) const cart = await carts.get('cart:5511999:items'); // Find keys by pattern const keys = await carts.keys('cart:*:items'); // Delete when done await carts.delete('cart:5511999:items'); ``` Also available as a dedicated entry point: `import { KV } from '@runflow-ai/sdk/kv'`. ## Static Shortcuts For one-off reads and writes, the static methods use the `default` namespace: ```typescript theme={null} import { KV } from '@runflow-ai/sdk'; await KV.set('flag:new-checkout', true); const enabled = await KV.get('flag:new-checkout'); await KV.has('flag:new-checkout'); // true await KV.delete('flag:new-checkout'); ``` ## Namespaces Namespaces isolate keys by domain. They are **implicit**: a namespace exists as soon as its first key is written, and disappears when its last key is deleted. ```typescript theme={null} const carts = KV.namespace('carts'); const flags = KV.namespace('feature-flags'); // Same key, different namespaces — no collision await carts.set('config', { maxItems: 50 }); await flags.set('config', { rollout: 0.25 }); // List every namespace in the tenant with key counts const all = await KV.namespaces(); // [{ namespace: 'carts', keys: 12 }, { namespace: 'feature-flags', keys: 3 }] // Delete every key in a namespace const removed = await carts.clear(); // returns number of deleted keys ``` Namespace names accept letters, digits, `.`, `_` and `-` (max 128 characters, must start with a letter or digit). ## TTL and Expiration Pass `ttl` (in seconds) to make an entry expire automatically. Expired keys behave exactly like missing keys — `get()` returns `null`, `has()` returns `false`, and listings skip them. ```typescript theme={null} // Expires in 30 minutes await carts.set('cart:5511999:items', cart, { ttl: 1800 }); // No ttl = persistent await carts.set('cart:5511999:address', address); ``` Setting a key **without** `ttl` clears any previous TTL — the entry becomes persistent. To keep a key expiring, pass `ttl` on every write. To inspect the expiration of a key, use `getEntry()`: ```typescript theme={null} const entry = await carts.getEntry('cart:5511999:items'); // { key, value, expiresAt: '2026-07-12T18:30:00.000Z', createdAt, updatedAt } // null when missing or expired ``` ## Pattern Search `keys()` and `getAll()` accept a glob pattern: `*` matches any sequence of characters, `?` matches exactly one. ```typescript theme={null} const carts = KV.namespace('carts'); // All item lists, any customer await carts.keys('cart:*:items'); // ['cart:5511888:items', 'cart:5511999:items'] // Keys and values together const entries = await carts.getAll('cart:5511999:*'); // [{ key: 'cart:5511999:items', value: {...}, expiresAt, ... }, ...] // No pattern = everything in the namespace const everything = await carts.getAll(); ``` Structure your keys with a consistent separator (`entity:id:field`) so patterns stay predictable — `cart:*:items`, `user:*:profile`, `order:2026-07-*`. ## Pagination Listings return up to 100 items by default (max 1000). For large namespaces, use `listKeys()` / `listEntries()`, which also return the total count: ```typescript theme={null} const page = await carts.listEntries({ pattern: 'cart:*', limit: 50, offset: 100 }); // { items: KvEntry[], total: 1240 } const keyPage = await carts.listKeys({ limit: 200 }); // { keys: [{ key, expiresAt, updatedAt }], total: 1240 } ``` ## Common Patterns ### Cart / conversation state with TTL ```typescript theme={null} const carts = KV.namespace('carts'); // Inside a tool: persist partial state that survives the session await carts.set(`cart:${phone}:items`, items, { ttl: 24 * 3600 }); // Next conversation, even days later on a new session: const items = await carts.get(`cart:${phone}:items`) ?? []; ``` ### Feature flags ```typescript theme={null} const flags = KV.namespace('feature-flags'); await flags.set('new-payment-flow', { enabled: true, rollout: 0.5 }); const flag = await flags.get('new-payment-flow'); if (flag?.enabled) { // ... } ``` ### Idempotency / deduplication ```typescript theme={null} const processed = KV.namespace('processed-events'); if (await processed.has(`webhook:${eventId}`)) { return { skipped: true }; // already handled } await processed.set(`webhook:${eventId}`, { at: new Date().toISOString() }, { ttl: 86400 }); ``` See [Abandoned Cart Recovery](/use-cases/abandoned-cart-recovery) for a complete working project combining these patterns — KV cart state, TTL as cleanup policy, idempotent reminders, and Memory-powered follow-ups. ## Managing Data in the Dashboard Every namespace is browsable in the platform under **KV Store** in the sidebar: * Browse namespaces with live key counts * Filter keys with the same glob patterns (`cart:*:items`) * Inspect full JSON values and TTLs * Delete individual keys or clear a whole namespace ## Local Development Outside the platform, the SDK follows the same convention as [Memory](/core-concepts/memory): with `RUNFLOW_ENV=development` (or `RUNFLOW_LOCAL_MEMORY=true`) values are stored as JSON files under `.runflow/kv/` in your project — no API required. ```bash theme={null} # Force a specific provider (optional) RUNFLOW_KV_PROVIDER=file # local JSON files RUNFLOW_KV_PROVIDER=api # Runflow API ``` You can also pass a provider explicitly — useful in tests: ```typescript theme={null} import { KV, FileKvProvider } from '@runflow-ai/sdk'; const kv = KV.namespace('test', { provider: new FileKvProvider('/tmp/kv-test') }); ``` To back the KV store with your own storage, implement the `KvProvider` interface (same pattern as [custom memory providers](/advanced/custom-memory-provider)). ## Limits and Validation | Constraint | Value | | --------------------------- | ---------------------------------------------- | | Value size | 256 KB per entry (JSON-serialized) | | Key length | 1–512 characters, no control characters | | Namespace | Letters, digits, `.`, `_`, `-` — max 128 chars | | TTL | Integer ≥ 1 (seconds); omit for persistent | | List page size | Default 100, max 1000 | | `null` / `undefined` values | Rejected — use `delete()` to remove a key | ## Method Reference | Method | Returns | Description | | --------------------------- | ------------------------------- | ------------------------------------------------ | | `get(key)` | `T \| null` | Value, or `null` when missing/expired | | `getEntry(key)` | `KvEntry \| null` | Value plus `expiresAt`, `createdAt`, `updatedAt` | | `set(key, value, { ttl? })` | `{ key, namespace, expiresAt }` | Create or overwrite a key | | `has(key)` | `boolean` | Whether the key exists and is not expired | | `delete(key)` | `boolean` | `true` if the key existed | | `keys(pattern?)` | `string[]` | Key names, optionally filtered by glob | | `getAll(pattern?)` | `KvEntry[]` | Entries with values, optionally filtered | | `listKeys(options?)` | `{ keys, total }` | Paginated key metadata | | `listEntries(options?)` | `{ items, total }` | Paginated entries | | `clear()` | `number` | Delete all keys in the namespace | | `listNamespaces()` | `KvNamespaceSummary[]` | All namespaces with key counts | All methods are also available as statics on `KV` (operating on the `default` namespace), plus `KV.namespace(name)` to get a scoped instance and `KV.namespaces()` to list namespaces. ## REST API Everything above is also exposed as authenticated REST endpoints under `/api/v1/runtime/v1/kv` — see the [Runtime REST API reference](/api-reference/introduction) if you're integrating without the SDK. ## Next Steps Conversation history — use KV for state, Memory for dialogue Read and write KV state from custom tools Complete project: KV cart state + Memory follow-ups Combine KV state with scheduled follow-ups # LLM Standalone Source: https://docs.runflow.ai/core-concepts/llm-standalone Use language models directly without creating agents The **LLM** module lets you call language models directly — no agents, no memory, no tools. Use it when you need a single LLM call for tasks like classification, data extraction, translation, or content generation. ## When to Use LLM vs Agent | Scenario | Use | | --------------------------------------- | ------------------------------ | | Conversation with memory and tools | [Agent](/core-concepts/agents) | | Single classification or categorization | **LLM Standalone** | | Extract structured data from text | **LLM Standalone** | | Generate content (emails, summaries) | **LLM Standalone** | | Translate text | **LLM Standalone** | | Pre-process input before an agent | **LLM Standalone** | ## Basic Usage ```typescript theme={null} import { LLM } from '@runflow-ai/sdk'; const llm = LLM.openai('gpt-4o', { temperature: 0.7, maxTokens: 2000, }); const response = await llm.generate('What is the capital of Brazil?'); console.log(response.text); console.log('Tokens:', response.usage); ``` ## With System Prompt Use a system prompt to control the LLM's behavior: ```typescript theme={null} const response = await llm.generate( 'The product arrived broken and I want my money back.', { system: `Classify the customer message into exactly one category: - REFUND_REQUEST - TECHNICAL_ISSUE - GENERAL_QUESTION - COMPLAINT - PRAISE Respond with ONLY the category name, nothing else.`, temperature: 0, } ); console.log(response.text); // "REFUND_REQUEST" ``` ## With Messages For multi-turn prompts or few-shot examples: ```typescript theme={null} const response = await llm.generate([ { role: 'system', content: `Extract structured data from customer messages. Return valid JSON only.`, }, { role: 'user', content: 'My name is João, email joao@test.com, I need help with order ORD-789', }, ]); const data = JSON.parse(response.text); // { name: "João", email: "joao@test.com", orderId: "ORD-789" } ``` ## Streaming For real-time output (long responses, content generation): ```typescript theme={null} const stream = llm.generateStream('Write a product description for a wireless headphone'); for await (const chunk of stream) { if (!chunk.done) { process.stdout.write(chunk.text); } } ``` ## Available Models ```typescript theme={null} import { LLM } from '@runflow-ai/sdk'; // OpenAI const gpt4 = LLM.openai('gpt-4o', { temperature: 0.7 }); const gpt4mini = LLM.openai('gpt-4o-mini', { temperature: 0.3 }); // Anthropic (Claude) const claude = LLM.anthropic('claude-sonnet-4-20250514', { temperature: 0.9, maxTokens: 4000, }); // AWS Bedrock const bedrockClaude = LLM.bedrock('anthropic.claude-3-5-sonnet-20241022-v2:0', { temperature: 0.8, }); // Groq (ultra-fast inference) const fast = LLM.groq('llama-3.3-70b-versatile', { temperature: 0.3 }); // Google Gemini const flash = LLM.gemini('gemini-2.5-flash', { temperature: 0.5 }); // xAI (Grok) const research = LLM.xai('grok-4-1-fast-reasoning', { temperature: 0.3 }); // Custom (OpenAI-compatible: Ollama, vLLM, LiteLLM, etc.) const local = LLM.custom('llama3', 'Ollama Local', { temperature: 0.7 }); ``` See [LLM Providers](/providers/llm-provider) for all supported providers and configuration options. ## Structured Output Force responses into valid JSON format using `responseFormat`: ```typescript theme={null} const extractor = LLM.openai('gpt-4o', { responseFormat: { type: 'json_object' } }); const result = await extractor.generate('List 3 colors with hex codes', { system: 'Respond with valid JSON only.' }); const data = JSON.parse(result.text); ``` For schema-validated JSON: ```typescript theme={null} const extractor = LLM.openai('gpt-4o', { responseFormat: { type: 'json_schema', json_schema: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' }, }, required: ['name', 'age'], additionalProperties: false, } } }); ``` See [Structured Output](/advanced/structured-output) for full provider support details. ## Thinking / Reasoning Enable extended thinking for complex tasks: ```typescript theme={null} const thinker = LLM.anthropic('claude-sonnet-4-6', { thinking: { type: 'enabled', budgetTokens: 10000 } }); const result = await thinker.generate('What is 17! / 15!?'); ``` Or use reasoning models that think natively: ```typescript theme={null} const reasoner = LLM.openai('o4-mini'); const researcher = LLM.xai('grok-4-1-fast-reasoning'); ``` See [Reasoning](/advanced/reasoning) for all provider options. ## Real-World Example: Intent Classifier Tool A common pattern is using LLM Standalone inside a tool to classify intent before the agent decides what to do: ```typescript tools/classify-intent.ts theme={null} import { createTool } from '@runflow-ai/sdk'; import { LLM } from '@runflow-ai/sdk'; import { z } from 'zod'; const classifier = LLM.openai('gpt-4o-mini', { temperature: 0 }); export const classifyIntentTool = createTool({ id: 'classify-intent', description: 'Classify customer message intent', inputSchema: z.object({ message: z.string().describe('The customer message to classify'), }), execute: async (params) => { try { const response = await classifier.generate(params.message, { system: `Classify the message into one category: - ORDER_STATUS: asking about an order, delivery, or tracking - REFUND: requesting money back or return - TECHNICAL: product issue or bug report - BILLING: payment, invoice, or charge question - GENERAL: anything else Respond with JSON: { "intent": "CATEGORY", "confidence": 0.0-1.0 }`, }); return JSON.parse(response.text); } catch (error) { return { intent: 'GENERAL', confidence: 0 }; } }, }); ``` ## Real-World Example: Pre-Processing in `main.ts` Use LLM Standalone to pre-process or enrich input before passing it to your agent: ```typescript main.ts theme={null} import { LLM } from '@runflow-ai/sdk'; import { identify, track } from '@runflow-ai/sdk/observability'; import { supportAgent } from './agent'; const classifier = LLM.openai('gpt-4o-mini', { temperature: 0 }); async function detectLanguage(text: string): Promise { const response = await classifier.generate(text, { system: 'Detect the language of this text. Respond with only the ISO 639-1 code (e.g., "pt", "en", "es").', }); return response.text.trim().toLowerCase(); } export async function main(input: any) { if (!input?.message) { return { error: 'message is required' }; } identify(input.email || input.phone || 'anonymous'); // Pre-process: detect language const language = await detectLanguage(input.message); const result = await supportAgent.process({ message: input.message, sessionId: input.sessionId, }); track('message_processed', { language }); return { message: result.message, language }; } ``` ## Real-World Example: Content Generation Generate structured content without needing an agent: ```typescript tools/generate-email.ts theme={null} import { createTool } from '@runflow-ai/sdk'; import { LLM } from '@runflow-ai/sdk'; import { z } from 'zod'; const writer = LLM.openai('gpt-4o', { temperature: 0.7 }); export const generateEmailTool = createTool({ id: 'generate-email', description: 'Generate a professional email based on context', inputSchema: z.object({ to: z.string().describe('Recipient name'), subject: z.string().describe('Email subject'), context: z.string().describe('What the email should communicate'), tone: z.enum(['formal', 'friendly', 'urgent']).describe('Email tone'), }), execute: async (params) => { try { const response = await writer.generate( `Write an email to ${params.to} about: ${params.context}`, { system: `You are a professional email writer. Tone: ${params.tone} Subject: ${params.subject} Write the email body only (no subject line, no "From/To" headers). Keep it concise — 2-3 paragraphs max.`, } ); return { success: true, subject: params.subject, body: response.text, tokensUsed: response.usage?.totalTokens, }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to generate email', }; } }, }); ``` ## Next Steps When you need memory and tools Use LLM inside tools Process audio and images Tips for effective agents # MCP (Model Context Protocol) Source: https://docs.runflow.ai/core-concepts/mcp Connect to external MCP servers and expose your connectors as an MCP Gateway Runflow supports the **Model Context Protocol (MCP)** in two ways: 1. **MCP Server Connector** — Connect to external MCP servers (Linear, GitHub, DeepWiki, etc.) and use their tools in your agents, just like any other connector 2. **MCP Gateway** — Expose your Runflow connectors (REST APIs, databases, MCP servers) as a single MCP endpoint for Claude Desktop, Cursor, or any MCP client *** ## MCP Server Connector Connect to any external MCP server and use its tools in your agents. The MCP server's tools are auto-discovered and become regular connector resources — no manual mapping needed. ### Setting Up **Portal:** 1. **Connectors > New Connector > MCP Server** 2. Enter the server URL (e.g., `https://mcp.linear.app/sse`) 3. Select transport: **SSE** or **Streamable HTTP** 4. Configure authentication if required (Bearer Token, Basic Auth, or OAuth2) 5. After creation, click **Sync Tools** — Runflow connects to the MCP server, calls `tools/list`, and creates a resource for each tool **What happens during Sync:** * Runflow opens a connection to the MCP server * Calls `tools/list` to discover all available tools * Creates a `ConnectorResource` for each tool (name, description, input schema) * Updates existing resources if the schema changed * Removes tools that no longer exist on the server * You can re-sync anytime to pick up changes ### Using in Agents MCP tools work exactly like REST or Database connector tools in agents. The agent calls them via the LLM's tool-use capability: ```typescript theme={null} import { Agent, openai, createConnectorTool } from '@runflow-ai/sdk'; const agent = new Agent({ name: 'Project Manager', instructions: `You help manage Linear issues and projects. When the user asks about issues, use the Linear tools to search and list. When they want to create an issue, gather title and description first.`, model: openai('gpt-4o'), tools: { listIssues: createConnectorTool({ connector: 'linear-mcp', resource: 'list-issues', }), createIssue: createConnectorTool({ connector: 'linear-mcp', resource: 'create-issue', }), getIssue: createConnectorTool({ connector: 'linear-mcp', resource: 'get-issue', }), }, }); // The agent decides which tool to call based on the user's message const result = await agent.process({ message: 'Show me the open bugs assigned to me', }); // Agent calls list-issues with { assignee: "me", status: "In Progress", label: "Bug" } ``` ### Direct Invocation (Without Agent) Call MCP tools directly in your code — useful in workflows, scripts, or custom logic: ```typescript theme={null} import { connector } from '@runflow-ai/sdk'; // Linear: list issues const issues = await connector('linear-mcp', 'list-issues', { assignee: 'me', limit: 10, status: 'In Progress', }); console.log(issues.data.issues); // Linear: create an issue const newIssue = await connector('linear-mcp', 'create-issue', { title: 'Fix login bug', description: 'Users are getting 500 errors on login', teamId: 'TEAM-123', priority: 2, }); console.log(newIssue.data.id); // "PROJ-456" // DeepWiki: search documentation const docs = await connector('deepwiki-mcp', 'ask-question', { repoName: 'anthropics/claude-code', question: 'How do MCP servers work?', }); ``` ### Using in Workflows Combine MCP tools with other steps in a workflow: ```typescript theme={null} import { flow, connector } from '@runflow-ai/sdk'; const bugTriageFlow = flow('bug-triage') .step('fetch-bugs', async () => { const result = await connector('linear-mcp', 'list-issues', { label: 'Bug', status: 'Triage', limit: 20, }); return { bugs: result.data.issues }; }) .step('classify', { agent: classifierAgent, prompt: ({ results }) => `Classify these bugs by severity:\n${JSON.stringify(results['fetch-bugs'].bugs.map(b => ({ id: b.id, title: b.title, description: b.description })))}`, }) .step('update-priorities', async ({ results }) => { const classifications = results.classify; for (const bug of classifications.bugs) { await connector('linear-mcp', 'update-issue', { issueId: bug.id, priority: bug.severity === 'critical' ? 1 : bug.severity === 'high' ? 2 : 3, }); } return { updated: classifications.bugs.length }; }) .build(); ``` ### Example: Project Management Agent with Linear A complete agent that manages a Linear workspace via MCP: ```typescript main.ts theme={null} import { identify } from '@runflow-ai/sdk/observability'; import { agent } from './agent'; export async function main(input: any) { identify(input.userId || input.metadata?.userId || 'anonymous'); return agent.process(input); } ``` ```typescript agent.ts theme={null} import { Agent, openai, createConnectorTool } from '@runflow-ai/sdk'; const linear = { listIssues: createConnectorTool({ connector: 'linear-mcp', resource: 'list-issues' }), getIssue: createConnectorTool({ connector: 'linear-mcp', resource: 'get-issue' }), createIssue: createConnectorTool({ connector: 'linear-mcp', resource: 'create-issue' }), updateIssue: createConnectorTool({ connector: 'linear-mcp', resource: 'update-issue' }), searchIssues: createConnectorTool({ connector: 'linear-mcp', resource: 'search-issues' }), listTeams: createConnectorTool({ connector: 'linear-mcp', resource: 'list-teams' }), }; export const agent = new Agent({ name: 'Linear Assistant', instructions: `You are a project management assistant connected to Linear. ## Capabilities - List, search, and filter issues - Create new issues with proper team, priority, and labels - Update issue status, assignee, and priority - List teams and their members ## Behavior - When listing issues, show a concise summary: ID, title, status, assignee - When creating issues, always ask for the team if not specified - Use "me" as assignee to filter the current user's issues - For priority: 1 = Urgent, 2 = High, 3 = Medium, 4 = Low`, model: openai('gpt-4o'), tools: linear, memory: { maxTurns: 20 }, observability: 'full', }); ``` ### Supported MCP Servers Any server implementing the MCP protocol works. Some popular ones: | Server | URL | Transport | Auth | Description | | -------------- | ------------------------------ | --------------- | ---------------------- | ----------------------------------------- | | **Linear** | `https://mcp.linear.app/sse` | SSE | Bearer Token (API Key) | Issue tracking, project management | | **DeepWiki** | `https://mcp.deepwiki.com/mcp` | Streamable HTTP | None | Search and read GitHub repo documentation | | **Sentry** | Varies | Streamable HTTP | Bearer Token | Error tracking and monitoring | | **Cloudflare** | Varies | Streamable HTTP | Bearer Token | Workers, KV, D1, R2 management | | **Notion** | Varies | Streamable HTTP | Bearer Token | Pages, databases, search | Check [mcp.run](https://mcp.run) or the [MCP servers directory](https://github.com/modelcontextprotocol/servers) for a full list of available MCP servers. ### Authentication MCP Server connectors support three authentication types. The credential is stored encrypted and injected automatically when connecting: | Type | How it works | Example | | ---------------- | --------------------------------------------------------------------- | -------------------------- | | **Bearer Token** | Sent as `Authorization: Bearer ` | Linear API Key, GitHub PAT | | **Basic Auth** | Username + password as Base64 | Internal services | | **OAuth2** | Client credentials or authorization code with automatic token refresh | Slack, HubSpot | *** ## MCP Gateway The MCP Gateway turns your Runflow connectors into an MCP endpoint. Configure in the portal what to expose, get a URL, and any MCP client can connect. ### Why Use It * **Unify tools** — Combine REST APIs, databases, and MCP servers in one endpoint * **No code needed** — Configure everything in the portal * **Secure** — API Key authentication, resource-level permissions, credentials stay server-side * **Observable** — Every call logged with input/output, duration, and status ### Creating a Gateway 1. **MCP Gateway > New Gateway** — set name, select API Key 2. **Add Tools** — pick resources from any of your connectors 3. **Copy the URL** — use in Claude Desktop, Cursor, or any MCP client ### Example: Multi-Connector Gateway A gateway that exposes tools from three different connector types: | Tool | Source | Type | | --------------------- | ------------------- | ----------- | | `list_issues` | Linear MCP Server | MCP\_SERVER | | `create_contact` | HubSpot REST API | REST\_API | | `execute_query` | PostgreSQL Database | DATABASE | | `read_wiki_structure` | DeepWiki MCP Server | MCP\_SERVER | All accessible from a single URL: ``` https://api.runflow.ai/api/v1/gateways/my-gateway/mcp?apiKey=sk-xxx ``` ### Connecting Claude Desktop Add to your `claude_desktop_config.json`: ```json theme={null} { "mcpServers": { "runflow": { "url": "https://api.runflow.ai/api/v1/gateways/my-gateway/mcp?apiKey=sk-your-key" } } } ``` Restart Claude Desktop. The tools appear in the tools menu and Claude can call them. ### Connecting Cursor In Cursor settings, add an MCP server with the gateway URL. ### Connecting via Claude Code ```bash theme={null} claude mcp add runflow --transport http \ "https://api.runflow.ai/api/v1/gateways/my-gateway/mcp?apiKey=sk-your-key" ``` ### Tool Management | Action | Description | | ------------------ | ------------------------------------------------------------------------- | | **Add Tools** | Select resources from any connector to expose | | **Enable/Disable** | Toggle individual tools without removing them | | **Alias** | Rename a tool for the MCP client (e.g., `list-issues` to `linear_issues`) | | **Remove** | Remove a tool from the gateway | ### Observability The **Logs** tab in the gateway detail page shows every tool call: * Status badge (success/error), tool name, duration * Expandable input/output for debugging * Filter by status and date range * Auto-refresh every 15 seconds * Pagination for high-volume gateways ### Security | Feature | Description | | --------------------------- | ------------------------------------------------------------------------ | | **API Key auth** | Each gateway has its own API Key | | **Resource-level** | Only explicitly added tools are exposed | | **Tenant isolation** | Gateways scoped to tenant, no cross-tenant access | | **Server-side credentials** | MCP clients never see your API keys, database passwords, or OAuth tokens | | **Stateless** | No session state stored, any pod can handle requests | *** ## Testing with curl If you prefer to test the MCP Gateway directly via HTTP before connecting an MCP client: ### List available tools ```bash theme={null} curl -X POST "https://api.runflow.ai/api/v1/gateways/my-gateway/mcp?apiKey=sk-your-key" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' ``` ### Call a tool ```bash theme={null} curl -X POST "https://api.runflow.ai/api/v1/gateways/my-gateway/mcp?apiKey=sk-your-key" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{ "jsonrpc": "2.0", "method": "tools/call", "params": { "name": "list_issues", "arguments": { "assignee": "me", "limit": 5 } }, "id": 2 }' ``` The `Accept: application/json, text/event-stream` header is required by the MCP Streamable HTTP transport. The response comes as a Server-Sent Event with the JSON-RPC result in the `data` field. ## Available MCP Tools (Runflow Public MCP) The **public MCP connector** — the same one used by the Claude.ai directory listing and the official [runflowai/claude-code-plugin](https://github.com/runflowai/claude-code-plugin) — exposes **116 tools** spanning agents, executions, prompts, connectors, knowledge bases, dashboards, flows, event aggregation, and more. It mirrors the internal MCP surface 1:1, so anything you can do via the API-key `/api/v1/mcp` endpoint is also reachable from Claude or any MCP-compatible client. Tools are gated by **OAuth scopes**: | Scope | Verbs | Notes | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `mcp:read` | `list_*`, `get_*`, `check_*`, `search_*`, `render_*`, `fetch_*` | Read-only. Granted by default for every connected client. | | `mcp:write` | `create_*`, `update_*`, `deploy_*`, `promote_*`, `publish_*`, `save_*`, `move_*`, `reorder_*`, `toggle_*`, `set_*`, `test_*`, `validate_*`, `compile_*`, `execute_*`, `chat_*`, `clear_*`, `export_*` | Mutations. Requested at OAuth consent. | | `mcp:admin` | every `delete_*` tool | Destructive. Requires explicit `mcp:admin` in the OAuth authorize request — clients registered via Dynamic Client Registration must opt in. | A token without the required scope sees the tool as if it didn't exist (no error, no listing). ### Inventory by category | Category | Read | Write | Admin | Examples | | ------------------------------------------------------- | ------ | ------ | ------ | ----------------------------------------------------------------------------------------------------------------------- | | Platform & docs | 6 | 0 | 0 | `get_platform_overview`, `get_recent_activity`, `search_docs`, `fetch_doc_page` | | Agents | 7 | 7 | 1 | `list_agents`, `get_agent_code`, `create_agent`, `deploy_agent`, `chat_with_agent`, `delete_agent` | | Executions & threads | 7 | 0 | 0 | `list_executions`, `get_execution_details`, `list_execution_threads` | | Execution reviews | 4 | 3 | 1 | `list_execution_reviews`, `create_execution_review`, `export_execution_reviews_for_training`, `delete_execution_review` | | Sessions | 2 | 1 | 0 | `list_sessions`, `get_session_history`, `clear_session` | | Prompts | 3 | 2 | 1 | `get_prompt`, `render_prompt`, `create_prompt`, `update_prompt`, `delete_prompt` | | Connectors (templates, instances, resources, execution) | 4 | 9 | 2 | `list_connector_templates`, `create_connector_instance`, `create_custom_connector`, `execute_connector`, `delete_*` | | Credentials | 2 | 3 | 1 | `list_credentials`, `create_credential`, `test_credential`, `delete_credential` | | Knowledge / vector stores / documents | 3 | 3 | 2 | `list_vector_stores`, `add_document`, `search_knowledge`, `delete_vector_store` | | Data sources | 1 | 3 | 1 | `list_datasources`, `create_datasource`, `test_datasource`, `delete_datasource` | | Triggers | 1 | 3 | 1 | `list_triggers`, `create_trigger`, `toggle_trigger`, `delete_trigger` | | LLM providers | 4 | 3 | 1 | `list_llm_providers`, `list_provider_models`, `create_llm_provider`, `delete_llm_provider` | | Dashboards — cards | 3 | 4 | 1 | `list_dashboard_cards`, `create_dashboard_card`, `move_dashboard_card`, `delete_dashboard_card` | | Dashboards — tabs | 1 | 3 | 1 | `list_dashboard_tabs`, `create_dashboard_tab`, `reorder_dashboard_tabs`, `delete_dashboard_tab` | | Event discovery | 2 | 0 | 0 | `get_event_names`, `get_event_properties` | | Event aggregation & query | 3 | 0 | 0 | `aggregate_events`, `query_events`, `render_dashboard_card` | | Flows (visual orchestration) | 1 | 5 | 0 | `get_flow_run`, `save_agent_flow`, `compile_flow`, `validate_flow`, `test_flow`, `deploy_flow` | | **Total** | **54** | **49** | **13** | **116 tools** | The canonical source of truth is the MCP `tools/list` response — categories above match the comment headers in the backend allowlist file, and may shift as the surface evolves. If you need an exhaustive list for a given build, call `tools/list` against the gateway you're connecting to. ### Connecting via OAuth (DCR) When a client connects through Dynamic Client Registration, the authorize request **must** include `mcp:admin` in `scope` to access any `delete_*` tool. A read-only listing in `claude_desktop_config.json` or the Claude.ai directory only needs the default `mcp:read mcp:write` scope. ## Next Steps Create REST, Database, and other connectors Build custom tools for agents Use MCP tools in agents Track gateway calls and metrics # Media Processing Source: https://docs.runflow.ai/core-concepts/media-processing Process audio, images, and other media types in your agents Runflow agents can process audio and images automatically. This is essential for WhatsApp integrations where users send voice messages and photos, and for HTTP clients that upload files directly to the agent via `multipart/form-data`. There are two entry points for media: * **`input.file`** — singular. Used by webhook handlers (Twilio/WhatsApp, Meta/Messenger) that resolve a single media file per inbound message. Auto-processed when `media.transcribeAudio` or `media.processImages` is enabled. * **`input.attachments[]`** — plural. Populated when the agent is invoked via `multipart/form-data` (one or more files in the same request). Auto-bridged to a multimodal chat message when `media.processAttachments` is enabled. Both paths produce the same downstream effect: the LLM receives a multimodal `user` message with image and/or file parts. Choose the entry point that matches how media reaches your agent. ## Audio Transcription Transcribe audio files to text using multiple providers: ```typescript theme={null} import { transcribe, Media } from '@runflow-ai/sdk'; // Standalone function (default: OpenAI Whisper) const result = await transcribe({ audioUrl: 'https://example.com/audio.ogg', language: 'pt', }); console.log(result.text); // "Olá, como vai?" // Using specific provider const result2 = await transcribe({ audioUrl: 'https://example.com/audio.ogg', provider: 'deepgram', language: 'pt', }); // Or via Media class const result3 = await Media.transcribe({ audioUrl: 'https://example.com/audio.ogg', provider: 'openai', }); ``` ## Supported Providers | Provider | Status | Description | | ------------ | --------- | ------------------------ | | `openai` | Available | OpenAI Whisper (default) | | `deepgram` | Available | Deepgram | | `assemblyai` | Available | AssemblyAI | | `google` | Available | Google Speech-to-Text | ## Agent with Auto Media Processing Configure agents to automatically handle audio and image files. When a user sends a voice message, it's transcribed before processing. When they send an image, it's analyzed with vision capabilities. ```typescript theme={null} import { Agent, openai } from '@runflow-ai/sdk'; const agent = new Agent({ name: 'WhatsApp Assistant', instructions: 'You are a helpful assistant.', model: openai('gpt-4o'), media: { transcribeAudio: true, processImages: true, audioProvider: 'openai', audioLanguage: 'pt', }, }); // Audio files are automatically transcribed before processing const result = await agent.process({ message: '', file: { url: 'https://zenvia.com/storage/audio.ogg', contentType: 'audio/ogg', caption: 'Voice message', }, }); // Images are automatically processed as multimodal const result2 = await agent.process({ message: 'What is in this image?', file: { url: 'https://example.com/image.jpg', contentType: 'image/jpeg', }, }); ``` ## Media Config Options | Option | Type | Description | | -------------------- | --------- | ----------------------------------------------------------------------------------------------------- | | `transcribeAudio` | `boolean` | Auto-transcribe `input.file` when it's audio (default: false) | | `processImages` | `boolean` | Auto-process `input.file` when it's an image (default: false) | | `processAttachments` | `boolean` | Auto-bridge `input.attachments[]` (from multipart uploads) into a multimodal message (default: false) | | `audioLanguage` | `string` | Language code (pt, en, es, etc.) | | `audioProvider` | `string` | openai \| deepgram \| assemblyai \| google | | `audioModel` | `string` | Provider-specific model | ## Multipart Uploads (Files & Images via HTTP) When you call the agent directly over HTTP and need to send files or images, post `multipart/form-data` to the agent endpoint. Runflow stores each upload, generates a short-lived URL, and delivers an `input.attachments[]` array to your agent. ```bash theme={null} curl -X POST "https://executor.runflow.ai/agent/?token=" \ -F "message=quanto custa esse produto?" \ -F "photo=@./produto.jpg" ``` ### Auto-bridge (recommended) Enable `media.processAttachments` to have the SDK build the multimodal message for you. No glue code, no transform — the agent receives the attachments and forwards them to the LLM automatically. ```typescript agent.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; export const agent = new Agent({ name: 'product-helper', model: openai('gpt-4o'), // any vision-capable model instructions: 'Help the user evaluate products.', media: { processAttachments: true, // ← opt-in }, }); ``` What happens: 1. Runflow stores the upload and delivers your agent an input that looks like this: ```json theme={null} { "message": "quanto custa esse produto?", "attachments": [{ "field": "photo", "name": "produto.jpg", "content_type": "image/jpeg", "size": 12345, "url": "https://.../produto.jpg", "object_key": "uploads///_produto.jpg" }] } ``` 2. The SDK builds a multimodal user message — text plus image — and sends it to the model. 3. The image reaches the LLM in whatever format that provider expects (OpenAI/Gemini get the URL directly; Anthropic, Groq, xAI and Azure all receive their respective shapes — handled transparently by the SDK). Routing rule (kept simple by design): * `content_type` starting with `image/` → `{ type: 'image_url', image_url: { url } }` * anything else → `{ type: 'file_url', file_url: { url }, name }` ### Manual transform (advanced) If you need custom routing — download a CSV to parse locally, OCR a PDF before sending, fan out images to different conversations — leave `processAttachments` off and transform the attachments yourself: ```typescript theme={null} import { buildAttachmentsContent } from '@runflow-ai/sdk/core'; export async function main(input: any) { // Use the same helper the SDK uses internally: const content = buildAttachmentsContent(input.attachments, input.message); // Or build it by hand, attachment by attachment: const parts = [{ type: 'text', text: input.message }]; for (const att of input.attachments ?? []) { if (att.content_type.startsWith('image/')) { parts.push({ type: 'image_url', image_url: { url: att.url } }); } else if (att.content_type === 'application/pdf') { // Maybe OCR locally, then send the extracted text. const text = await extractPdfText(att.url); parts.push({ type: 'text', text }); } else { parts.push({ type: 'file_url', file_url: { url: att.url }, name: att.name }); } } return await agent.process({ ...input, messages: [{ role: 'user', content: parts }], }); } ``` ### Limits | Limit | Default | | ----------------------- | ---------- | | Max per file | 25 MiB | | Max total per request | 100 MiB | | Max per text form field | 1 MiB | | Presigned URL TTL | 15 minutes | Oversize uploads return **HTTP 413 AttachmentTooLarge**. Malformed multipart returns **400 InvalidMultipart**. The URLs in `input.attachments[].url` are short-lived — your agent and the LLM provider should consume them within minutes of the request. For documents that need to live longer, persist them yourself (e.g., copy to your own storage). ### Provider support for attachments | Provider | Images | Files (PDF, CSV, …) | | ------------------- | ------------------------------ | -------------------------------------------- | | OpenAI | URL or base64 | `file_id` (uploaded via `/runtime/v1/files`) | | Azure OpenAI | URL or base64 | text label only | | Anthropic / Bedrock | URL or base64 | text label only | | Gemini | base64 inline (or `data:` URI) | text label only | | Groq / xAI | URL or base64 (vision models) | text label only | For non-image files on providers that don't support arbitrary documents, the SDK emits a `[File: ]` text placeholder so the model sees something coherent. If you need the model to actually read a PDF/CSV, parse it locally first and send the extracted text. ## Real-World Example: WhatsApp Support Agent A complete WhatsApp agent that handles text, voice messages, and photos. Users can send a voice message to explain their issue or a photo of a damaged product. ### Project Structure ``` whatsapp-support/ ├── main.ts ├── agent.ts ├── tools/ │ ├── index.ts │ └── create-ticket.ts ├── .runflow/ │ └── rf.json ├── package.json └── tsconfig.json ``` ### Agent with Media ```typescript agent.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; export const whatsappAgent = new Agent({ name: 'WhatsApp Support', instructions: `You are a customer support agent for WhatsApp. ## Behavior - Respond in the customer's language - Be concise — WhatsApp messages should be short - When the customer sends a voice message, you'll receive the transcription — respond naturally - When the customer sends a photo, analyze it and respond accordingly ## Tools - Use create-ticket when the issue needs human follow-up - If a customer sends a photo of a damaged product, create a ticket with priority 'high'`, model: openai('gpt-4o'), memory: { maxTurns: 30 }, media: { transcribeAudio: true, processImages: true, audioProvider: 'openai', audioLanguage: 'pt', }, tools: { createTicket: createTicketTool, }, observability: 'full', }); ``` ### Main Entry Point ```typescript main.ts theme={null} import { identify, track } from '@runflow-ai/sdk/observability'; import { whatsappAgent } from './agent'; function parseWhatsAppInput(input: any) { // Zenvia webhook format if (input.message?.from) { const content = input.message.contents?.[0]; return { phone: input.message.from, message: content?.text || content?.caption || '', file: content?.fileUrl ? { url: content.fileUrl, contentType: content.fileMimeType, caption: content.caption, } : undefined, channel: 'zenvia', }; } // Direct API return { phone: input.phone, message: input.message || '', file: input.file, channel: input.channel || 'api', }; } export async function main(input: any) { const { phone, message, file, channel } = parseWhatsAppInput(input); if (!phone) { return { error: 'phone is required' }; } if (!message && !file) { return { error: 'message or file is required' }; } identify(phone); // Track media type for analytics const mediaType = file?.contentType?.startsWith('audio') ? 'audio' : file?.contentType?.startsWith('image') ? 'image' : 'text'; track('whatsapp_message_received', { channel, mediaType }); try { const result = await whatsappAgent.process({ message, sessionId: `whatsapp_${phone}`, file, }); return { message: result.message, phone, }; } catch (error) { console.error('[whatsapp-support] Error:', error); return { error: 'An error occurred processing your message' }; } } ``` ## Real-World Example: Transcription Tool When you need more control over transcription (e.g., saving the transcription, analyzing it), use `transcribe()` inside a tool: ```typescript tools/process-voice.ts theme={null} import { createTool } from '@runflow-ai/sdk'; import { transcribe } from '@runflow-ai/sdk'; import { track } from '@runflow-ai/sdk/observability'; import { z } from 'zod'; export const processVoiceTool = createTool({ id: 'process-voice', description: 'Transcribe and analyze a voice message', inputSchema: z.object({ audioUrl: z.string().describe('URL of the audio file'), language: z.string().optional().describe('Language code (default: pt)'), }), execute: async (params) => { try { const result = await transcribe({ audioUrl: params.audioUrl, language: params.language || 'pt', provider: 'openai', }); track('voice_transcribed', { language: params.language || 'pt', textLength: result.text.length, }); return { success: true, text: result.text, language: params.language || 'pt', }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Transcription failed', }; } }, }); ``` ## Tips For WhatsApp agents, always enable both `transcribeAudio` and `processImages`. Users frequently send voice messages instead of typing, especially on mobile. Audio transcription adds latency to your agent's response (typically 1-3 seconds depending on audio length). Consider tracking transcription time with `track()` to monitor performance. ## Next Steps Configure agents with media Build custom media tools Identify users in WhatsApp Tips for effective agents # Memory Source: https://docs.runflow.ai/core-concepts/memory Intelligent conversation history management The **Memory** system intelligently manages conversation history. ## Memory vs KV Store Memory is built for **conversation context** — message history, summaries, who spoke last, session status. Its content is *managed*: old turns are trimmed by `maxTurns`/`maxTokens` and compacted by summarization, so what you appended yesterday may only survive as part of a summary. That makes Memory the wrong place to park business data. If you need a value to come back **exactly as you stored it** — a cart, a feature flag, a counter, a customer preference — use the [KV Store](/core-concepts/kv-store) instead. | | Memory | KV Store | | --------- | -------------------------------------------- | ---------------------------------------- | | Stores | Messages, summaries, session metadata | Any JSON value | | Scope | Per conversation / entity | Tenant-wide (all agents) | | Lifecycle | Trimmed and summarized automatically | Persistent until deleted (or TTL) | | Read back | Formatted history, semantic search | Exact value, glob pattern search | | Use for | Dialogue context, follow-ups, session status | Carts, flags, counters, idempotency keys | Appending fake messages to conversation history to persist arbitrary data is unreliable — summarization can compact it away and every message inflates the context sent to the LLM. Store data in the [KV Store](/core-concepts/kv-store); keep Memory for the conversation. See [Abandoned Cart Recovery](/use-cases/abandoned-cart-recovery) for the two working together. ## Memory Integrated in Agent ```typescript theme={null} const agent = new Agent({ name: 'Memory Agent', instructions: 'You remember everything.', model: openai('gpt-4o'), memory: { maxTurns: 20, // Limit turns maxTokens: 4000, // Limit tokens summarizeAfter: 50, // Summarize after N turns summarizePrompt: 'Create a concise summary with key facts and action items', summarizeModel: openai('gpt-4o-mini'), // Cheaper model for summaries }, }); ``` ## Standalone Memory Manager ```typescript theme={null} import { Memory } from '@runflow-ai/sdk'; // Using static methods (most common - 99% of cases) await Memory.append({ role: 'user', content: 'Hello!', timestamp: new Date(), }); await Memory.append({ role: 'assistant', content: 'Hi! How can I help you?', timestamp: new Date(), }); // Get formatted history const history = await Memory.getFormatted(); console.log(history); // Get recent messages const recent = await Memory.getRecent(5); // Last 5 turns // Search in memory const results = await Memory.search('order'); // Check if memory exists const exists = await Memory.exists(); // Get full memory data const data = await Memory.get(); // Clear memory await Memory.clear(); ``` ## Memory with User Identification ```typescript theme={null} import { Memory } from '@runflow-ai/sdk'; import { identify } from '@runflow-ai/sdk/observability'; // Identify user identify('+5511999999999'); // Memory automatically uses the context await Memory.append({ role: 'user', content: 'My order number is 12345', timestamp: new Date(), }); // Memory is automatically bound to the phone number ``` ## Custom Memory Key ```typescript theme={null} // Create memory with custom key const memory = new Memory({ memoryKey: 'custom_key_123', maxTurns: 10, }); // Now use instance methods await memory.append({ role: 'user', content: 'Hello', timestamp: new Date() }); const history = await memory.getFormatted(); ``` ## Cross-Session Access ```typescript theme={null} // Access memory from different sessions (admin, analytics, etc) const dataUser1 = await Memory.get('phone:+5511999999999'); const dataUser2 = await Memory.get('email:user@example.com'); // Search across multiple sessions const results = await Promise.all([ Memory.search('bug', 'user:123'), Memory.search('bug', 'user:456'), Memory.search('bug', 'user:789'), ]); // Get recent from specific session const recent = await Memory.getRecent(5, 'session:abc123'); // Clear specific session await Memory.clear('phone:+5511999999999'); ``` ## Listing Sessions `Memory.list()` queries all sessions for the current agent with filtering. Each result includes the last message, so you can decide what to do without loading the full conversation. ```typescript theme={null} import { Memory } from '@runflow-ai/sdk'; // List all sessions for this agent const all = await Memory.list(); // List sessions inactive for 4+ hours const inactive = await Memory.list({ lastInteractionBefore: new Date(Date.now() - 4 * 60 * 60 * 1000), }); // List only sessions with a specific status const inProgress = await Memory.list({ status: 'in_progress', }); // Combine filters const needsFollowUp = await Memory.list({ lastInteractionBefore: new Date(Date.now() - 4 * 60 * 60 * 1000), limit: 50, }); ``` Each session in the result includes: | Field | Type | Description | | -------------- | --------- | ------------------------------------------------------------------- | | `id` | `string` | Session ID | | `entityType` | `string?` | Entity type from `identify()` (e.g., `phone`, `email`) | | `entityValue` | `string?` | Entity value (e.g., `+5511999999999`) | | `status` | `string?` | Session status (null = in progress, or `qualified`, `closed`, etc.) | | `updatedAt` | `string` | Timestamp of last interaction | | `messageCount` | `number` | Total messages in session | | `summary` | `string?` | Auto-generated conversation summary | | `lastMessage` | `object?` | Last message: `{ role, content, timestamp }` | ### Checking Who Spoke Last Use `lastMessage.role` to know if the user or the agent was the last to speak — useful for deciding whether to follow up: ```typescript theme={null} const inactive = await Memory.list({ lastInteractionBefore: new Date(Date.now() - 4 * 60 * 60 * 1000), }); for (const session of inactive) { // Skip sessions already closed/qualified if (session.status) continue; // Only follow up if the agent was the last to speak (user didn't respond) if (session.lastMessage?.role !== 'assistant') continue; await agent.process({ message: 'Follow up with this lead.', entityType: session.entityType, entityValue: session.entityValue, channel: 'whatsapp', }); } ``` Don't use `identify()` in a loop — it sets a global singleton that would get overwritten on each iteration. Pass `entityType`/`entityValue` directly in the `agent.process()` input instead. ## Session Status Mark sessions with a status to track their lifecycle. Status is stored in session metadata — no migration needed. ```typescript theme={null} import { Memory } from '@runflow-ai/sdk'; // Set status on the current session (requires identify() first) await Memory.setStatus('qualified'); await Memory.setStatus('closed'); await Memory.setStatus('nurturing'); ``` ### Status via Tools (Recommended) The best pattern is to let **tools set the status** — the LLM decides when to call them based on the conversation: ```typescript theme={null} import { createTool } from '@runflow-ai/sdk'; import { Memory } from '@runflow-ai/sdk'; const qualifyLeadTool = createTool({ id: 'qualify-lead', description: 'Score the lead based on qualification criteria. This marks the session status.', inputSchema: z.object({ score: z.number().min(0).max(10), }), execute: async (params) => { const status = params.score >= 7 ? 'qualified' : 'nurturing'; await Memory.setStatus(status); return { score: params.score, status }; }, }); const closeConversationTool = createTool({ id: 'close-conversation', description: 'Close this conversation. Use when lead is not interested or conversation is finished.', inputSchema: z.object({ reason: z.enum(['not_interested', 'wrong_contact', 'completed', 'other']), }), execute: async (params) => { await Memory.setStatus('closed'); return { closed: true, reason: params.reason }; }, }); ``` The developer never calls `Memory.setStatus()` directly — the tools do it. The agent's instructions guide the LLM to use the right tool at the right time. ### Status Lifecycle ``` New conversation → status: null (in progress) Lead qualified (score >= 7) → tool sets "qualified" Lead scored low (score < 7) → tool sets "nurturing" Lead not interested → tool sets "closed" CRON checks inactive leads → Memory.list() skips sessions with status ``` See the [SDR Agent with Follow-ups](/use-cases/sdr-follow-up) use case for a complete working example using `Memory.list()`, `Memory.setStatus()`, and scheduled callbacks. ## Custom Summarization ```typescript theme={null} // Agent with custom summarization const agent = new Agent({ name: 'Smart Agent', model: openai('gpt-4o'), memory: { summarizeAfter: 30, summarizePrompt: `Summarize in 3 bullet points: - Main issue discussed - Solution provided - Next steps`, summarizeModel: anthropic('claude-3-haiku'), // Fast & cheap }, }); // Manual summarization with custom prompt const summary = await Memory.summarize({ prompt: 'Extract only the key decisions from this conversation', model: openai('gpt-4o-mini'), }); ``` ## Memory Configuration Options | Option | Type | Description | | ----------------- | --------------- | ---------------------------------- | | `maxTurns` | `number` | Maximum conversation turns to keep | | `maxTokens` | `number` | Maximum tokens to keep | | `summarizeAfter` | `number` | Trigger summary after N turns | | `summarizePrompt` | `string` | Custom prompt for summarization | | `summarizeModel` | `ModelProvider` | Custom model for summarization | | `memoryKey` | `string` | Custom memory key | ## Cross-agent memory administration The `Memory` module operates on the **caller's own** agent context — its key is prefixed with the caller's agentId so each agent has its own namespace. To curate **another** agent's memory (inject a system message, clear stale sessions, audit messages, summarize), use the [`MemoryAdmin`](/core-concepts/cross-agent#memory-admin) module from the cross-agent SDK. Same six operations, but you pass the target agent explicitly and the backend prefixes the key with the target's id instead of the caller's. ```typescript theme={null} import { MemoryAdmin } from '@runflow-ai/sdk/memory-admin'; const admin = new MemoryAdmin(); // List slots updated in the last 7 days const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); const { sessions } = await admin.list('customer-support', { dateFrom: since, dateField: 'updated_at', limit: 50, }); // Inject a system message await admin.append('customer-support', 'phone:+5511999999999', { role: 'system', content: 'IMPORTANTE: cliente prioritário, responder em <2min.', }); // LLM summary of the slot const { summary } = await admin.summarize( 'customer-support', 'phone:+5511999999999', { prompt: 'Resume em até 5 bullets, em português:' }, ); ``` Tenant-isolated: cross-tenant references return 404. See [Cross-Agent SDK](/core-concepts/cross-agent) for the full reference and recipes (curator agent, follow-up agent). ## Next Steps Learn about context management Operate on another agent's memory and executions Create custom tools Complete project: conversation in Memory, cart state in KV # Metrics Registry Source: https://docs.runflow.ai/core-concepts/metrics Code-first dashboard tabs and cards — declare metrics in agent code and sync them to the platform The **Metrics Registry** lets agent code declare its own dashboard tabs and cards, then sync them to the platform with a single call. It mirrors the canonical shape used by the CLI's `rf metrics sync` command — same validation, same idempotency — so the same definitions work whether you sync at deploy time or from inside the agent process. Available since `@runflow-ai/sdk@1.1.12`. The portal-based dashboard editor still works — Metrics Registry is a code-first alternative for teams that want metrics tracked as code. ## When to use * You want dashboard definitions in version control next to the agent code. * You're rolling out the same dashboard across many agents and want the definitions reusable. * You're migrating an existing portal-built dashboard into code (legacy field shapes like `tableMode` are auto-normalized). If a one-off card is fine, the portal **Metrics** tab is faster — see [Business Event Tracking](/core-concepts/observability#business-event-tracking) for that flow. ## Quick start ```typescript theme={null} import { metrics } from '@runflow-ai/sdk/observability'; // 1. Declare a tab (idempotent on `name`) metrics.defineTab({ name: 'Vendas' }); // 2. Declare a card on that tab metrics.defineCard({ tab: 'Vendas', title: 'Funil de checkout', cardType: 'funnel', config: { funnelMode: 'multi_event', steps: [ { eventName: 'cart_open', label: 'Carrinho aberto' }, { eventName: 'checkout_started', label: 'Início checkout' }, { eventName: 'sale', label: 'Pagamento' }, ], }, gridLayout: { x: 0, y: 0, w: 6, h: 5 }, }); // 3. Push to the platform await metrics.sync(); ``` The registry is a **process-wide singleton** — `import { metrics }` returns the same instance from every module, so you can split declarations across files freely. ## Validation is synchronous `defineCard()` validates against the shared Zod schemas at registration time. Shape mistakes throw immediately: ```typescript theme={null} metrics.defineCard({ tab: 'Vendas', title: 'My card', cardType: 'funnel', config: { /* missing required `steps` */ }, }); // ❌ Throws: metrics.defineCard("My card"): config.steps: Required ``` Legacy aliases used by the portal editor are auto-normalized before validation (for example, the old `tableMode` field maps to the new `mode` field), so migrating an old definition usually doesn't require any changes. Each `(cardType, title)` pair must be unique in the registry — registering twice throws to prevent silent dupes. ## `sync()` is two-phase Tabs are upserted first, then cards. The platform resolves each `tab` name to a `tabId` before the card upsert, so cards always land on the right tab: ```typescript theme={null} const result = await metrics.sync(); // { // agentId: 'agent-uuid', // tabs: { created: 1, updated: 2, total: 3 }, // cards: { created: 4, updated: 1, failed: 0, total: 5 }, // } ``` Per-card idempotency uses a deterministic key (`${cardType}:${title}:${tab ?? ''}`), so repeated syncs only update — they never duplicate. By default failures are logged and skipped; pass `{ strict: true }` to make any failure throw. ## Configuration `sync()` reads credentials from these in order: | Source | Variable | | --------------------- | --------------------------------------------------------- | | Explicit option | `metrics.sync({ agentId, baseUrl, apiKey })` | | Environment | `RUNFLOW_API_URL`, `RUNFLOW_API_KEY`, `RUNFLOW_AGENT_ID` | | Runtime context (SDK) | Set by the platform when running inside a deployed agent. | Missing any of the three throws — surface them in your config before calling `sync()`. ## CLI parity The CLI command `rf metrics sync` posts the exact same shape to the same endpoints. You can switch between code-first and CLI-driven dashboards without recomputing anything — the platform dedupes by the same idempotency key on both sides. ## Card types `defineCard()` supports the same card catalog as the portal editor — `number`, `rate`, `line`, `bar`, `pie`, `funnel`, `table`, `gauge`, etc. Each type expects its own `config` shape; see the portal **Metrics** tab for the canonical UI of every card type. ## Listing and consuming cards You can read back what's published — and even drive your own renderer — over the runtime REST API: * `GET /api/v1/runtime/observability/dashboard-cards?agentId=` — list every card configured for an agent (returns `cardType`, `config`, `gridLayout`, `tabId`). * `POST /api/v1/runtime/observability/dashboard-cards` — the same endpoint `metrics.sync()` calls. Safe to invoke directly from server-to-server jobs. * `GET /api/v1/runtime/observability/dashboard-tabs?agentId=` — list configured tabs. * `POST /api/v1/runtime/observability/dashboard-tabs` — idempotent upsert (used by `metrics.defineTab(...) → metrics.sync()`). * `POST /api/v1/runtime/v1/observability/events` — push events from outside the SDK. * `GET /api/v1/runtime/v1/observability/events/feed` — most-recent-first event feed (filter by `agentId`, `eventName`, paginated). * `POST /api/v1/runtime/v1/observability/events/query` — table-style query, `mode: 'raw'` for individual rows or `mode: 'aggregate'` with `groupBy` + `metrics` for grouped reports. For **KPI-style aggregation** (single-number queries — `sum`, `avg`, `count`, `rate`, `distinct_count`, `group_by` with optional `dateGrouping` for time series), use the MCP surface: * `aggregate_events` — returns the raw value or series. Same shape the portal Metrics tab uses. * `query_events` — same as the REST endpoint, also available via MCP. * `render_dashboard_card` — pass a `cardId` and get back the current value, dispatching on `cardType` + `config` (supports number / rate / line / bar / pie / gauge / table / funnel). See [REST Endpoints → Dashboards & Events](/api-reference/api-client#dashboards-events) and [MCP → Available MCP Tools](/core-concepts/mcp#available-mcp-tools-runflow-public-mcp) for the full shapes. Discovery endpoints (`event_names`, `event_properties`, `property-values`) remain portal-only over REST. They're also exposed as MCP tools (`get_event_names`, `get_event_properties`) for AI clients. ## Next Steps Emit the events your cards aggregate Signatures and options # Notifications & Alerts Source: https://docs.runflow.ai/core-concepts/notifications Proactive monitoring — automatic failure alerts plus custom alerts emitted from your agents The **Notifications** system proactively monitors every agent execution and delivers alerts to the portal in real time — the bell in the top bar updates over WebSocket, no refresh needed. Failures are detected automatically, and your agent code can emit **custom alerts** with a single SDK call. ## Automatic alerts The platform watches every execution and raises an alert without any instrumentation on your side: | Alert type | Triggered when | Severity | Typical latency | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------------- | | **Execution failure** | An execution ends with an uncaught error (a `throw` that escapes your handler) | warning | seconds | | **Execution timeout** | An execution exceeds the runtime cap, or gets stuck in `RUNNING` beyond the limit — including when the runtime itself crashed mid-flight | critical | under a minute | | **Connector failure** | A connector call fails, even if your code catches the error and the execution succeeds | warning | seconds | Detection is two-layered: most alerts fire from the trace pipeline within seconds, and an independent watchdog reads the execution store directly — so a worker that dies without reporting anything still produces an alert. Both staging and production are monitored; every notification carries an environment badge. ### Grouping and deduplication Alerts are designed to inform, not to flood: * The **first** failure of an agent notifies immediately. * Subsequent failures of the same agent and type within a **15-minute window** increment the same notification ("12 failures in the last 15 min") instead of creating new ones. * Each execution is counted **once**, even when detected by more than one path. * Within a group, severity only escalates (a `critical` event upgrades a `warning` group, never the opposite). ## Custom alerts from the SDK Available from `@runflow-ai/sdk` **v1.4.1**. Call `sendNotification()` anywhere inside an execution — no IDs required, everything is resolved from the execution context: ```typescript theme={null} import { sendNotification } from '@runflow-ai/sdk'; // Simple warning (default severity) sendNotification('Customer record missing in CRM'); // Critical alert with details sendNotification('Stock depleted in ERP', { severity: 'critical', body: `SKU ${sku} returned zero availability during order flow`, }); // Deduplicate by your own key: the same document only alerts once per execution sendNotification('Invoice validation failed', { severity: 'warning', dedupeKey: invoiceId, }); ``` ### Options | Option | Type | Default | Description | | ----------- | ------------------------- | ----------- | ------------------------------------------------------------------------------------ | | `severity` | `'warning' \| 'critical'` | `'warning'` | Controls the notification severity; `critical` escalates an open alert group | | `body` | `string` | — | Extra detail shown in the notification body | | `dedupeKey` | `string` | — | Distinct alerts within one execution deduplicate by this key (defaults to the title) | ### Behavior * **Fire-and-forget** — `sendNotification()` never throws and never blocks your agent's execution. * The alert title you pass becomes the notification title in the portal, grouped per agent like automatic alerts. * Custom alerts appear with the type **Alert** in the bell and history filters. `sendNotification()` is designed to run **inside the Runflow runtime**. Outside of it (a plain local script with no execution context), the alert is discarded server-side since there is no execution to attach it to. ## Where notifications surface Real-time badge in the top bar. Click an alert to jump straight to the affected agent. Full paginated history with filters: environment, type, severity and unread-only. The dashboard ranks all agents by health (error rate, timeouts, connector failures), and each agent's overview shows its health badge plus the most common errors, grouped and normalized. Users with access to multiple tenants can pick which ones to watch — the bell aggregates alerts across all of them, tagged per tenant. ## Health metrics The health views are computed from real execution data over a 24h/7d window: * **Executions and failures** per agent (combining the trace layer with the execution store of record) * **Error rate** with health classification: healthy (\<5%), degraded (5–20%), critical (≥20%) * **Timeouts** and **stuck executions** * **Connector failures** per agent * **Most common errors**, grouped by normalized message (IDs and numbers collapsed so identical failures group together) # Observability Source: https://docs.runflow.ai/core-concepts/observability Automatic tracing, business event tracking, and performance metrics The **Observability** system automatically collects execution traces for analysis and debugging, and provides a `track()` API for emitting custom business events that power real-time dashboards. ## Automatic Tracing (Agent) ```typescript theme={null} // Traces are collected automatically const agent = new Agent({ name: 'Support Agent', instructions: 'Help customers.', model: openai('gpt-4o'), }); // Each execution automatically generates traces await agent.process({ message: 'Help me', companyId: 'company_123', // Optional sessionId: 'session_456', // Optional executionId: 'exec_123', // Optional threadId: 'thread_789', // Optional }); ``` ## Automatic Tracing (Workflow) Workflows automatically trace every step with full hierarchy: ```typescript theme={null} const workflow = flow({ id: 'validate-order', name: 'Order Validation', inputSchema: z.object({ orderId: z.string() }), outputSchema: z.any(), }) .step('fetch', async (input) => db.getOrder(input.orderId)) .step('validate', async (input) => ({ valid: input.status === 'active' })) .agent('respond', responseAgent) .build(); await workflow.execute({ orderId: 'ORD-123' }); // Generates trace hierarchy: // workflow_execution > "Order Validation" // ├── workflow_step > "fetch" [function] // ├── workflow_step > "validate" [function] // └── workflow_step > "respond" [agent] // └── agent_execution // └── llm_call ``` Each step type (`function`, `agent`, `connector`, `condition`, `switch`, `foreach`, `parallel`) is traced with its own color and label in the portal. ## Verbose Tracing Mode Control how much data is saved in traces. Works identically for **Agent** and **Workflow**. **Modes:** * **`full`**: Complete data including prompts and responses (default) * **`standard`**: Balanced metadata with truncation * **`minimal`**: Disables tracing entirely (no traces sent) **Simple API (string preset):** ```typescript theme={null} const agent = new Agent({ name: 'My Agent', model: openai('gpt-4o'), observability: 'minimal' // Disable traces completely }); const workflow = flow({ id: 'my-workflow', inputSchema, outputSchema, observability: 'standard' // Truncate large inputs/outputs }).step('a', handler).build(); ``` **Granular Control (object config):** ```typescript theme={null} const agent = new Agent({ name: 'My Agent', model: openai('gpt-4o'), observability: { mode: 'standard', // Base mode verboseLLM: true, // Override: save complete prompts verboseMemory: false, // Override: keep memory minimal verboseTools: true, // Override: save tool data (default) maxInputLength: 5000, // Truncate large inputs maxOutputLength: 5000, // Truncate large outputs } }); ``` ## Trace Interceptor (onTrace) Intercept, modify, or cancel traces before they are sent. Available in **Agent**, **Workflow**, and **standalone logging**. ```typescript theme={null} // Agent const agent = new Agent({ observability: { onTrace: (trace) => { // Remove sensitive data if (trace.input?.cpf) delete trace.input.cpf; if (trace.input?.password) delete trace.input.password; return trace; } } }); // Workflow const workflow = flow({ id: 'pipeline', inputSchema, outputSchema, observability: { onTrace: (trace) => { // Cancel LLM traces (only keep step-level) if (trace.type === 'llm_call') return null; return trace; } } }).step('a', handler).build(); // Standalone logging import { configureLogging } from '@runflow-ai/sdk'; configureLogging({ onTrace: (trace) => { // Send to external system datadog.sendTrace(trace); return trace; } }); ``` **Return values:** * Return the trace (modified or not) to send it * Return `null` to cancel (trace is not sent) * Return `void` to send unchanged ## Trace Hierarchy (startSpan) Create parent-child relationships between custom logs for structured traces: ```typescript theme={null} import { startSpan, log } from '@runflow-ai/sdk'; async function processBatch(items: any[]) { // Create a parent span const batch = startSpan('process-batch'); for (const item of items) { // Child logs grouped under the parent log('process-item', { input: { id: item.id }, output: { status: 'ok' } }, { parentId: batch.traceId }); } // Close the parent span batch.end({ output: { total: items.length } }); } ``` This produces a hierarchical trace in the portal: ``` custom_event > "process-batch" ├── custom_event > "process-item" ├── custom_event > "process-item" └── custom_event > "process-item" ``` ## Custom Executions (Non-Agent Flows) For scenarios without `agent.process()` (document analysis, batch processing, etc.): ```typescript theme={null} import { identify, startExecution, log } from '@runflow-ai/sdk/observability'; export async function analyzeDocument(docId: string) { // 1. Identify context identify({ type: 'document', value: docId }); // 2. Start custom execution const exec = startExecution({ name: 'document-analysis', input: { documentId: docId } }); try { // 3. Process with LLM calls const llm = LLM.openai('gpt-4o'); const text = await llm.chat("Extract text from document..."); exec.log('text_extracted', { length: text.length }); const category = await llm.chat(`Classify this: ${text}`); exec.log('document_classified', { category }); // exec.log() automatically parents to the execution span const summary = await llm.chat(`Summarize: ${text}`); // 4. Finish with custom output await exec.end({ output: { summary, category, documentId: docId } }); return { summary, category }; } catch (error) { exec.setError(error); await exec.end(); throw error; } } ``` ## Custom Logging Log custom events within any execution: ```typescript theme={null} import { log, logEvent, logError } from '@runflow-ai/sdk/observability'; // Simple log log('cache_hit', { key: 'user_123' }); // Structured log with parent log('step_completed', { input: { orderId: '123' }, output: { valid: true }, }, { parentId: parentSpan.traceId }); // Structured log logEvent('validation', { input: { orderId: '123', amount: 100 }, output: { valid: true, score: 0.95 }, metadata: { rule: 'fraud_detection' } }); // Error log try { await riskyOperation(); } catch (error) { logError('operation_failed', error); throw error; } ``` ## Conversation Messages Available since `@runflow-ai/sdk@1.1.10`. Use `message()` to record a turn of a conversation. Each call emits a `conversation_message` trace that the Runflow portal renders as a **chat bubble**: user inbound on the left, assistant outbound on the right. The portal switches automatically to chat view when an execution has at least one `conversation_message` trace — no flag, no channel hint. The thread sidebar preview also updates to show the latest user/assistant text instead of raw envelope JSON. ```typescript theme={null} import { message } from '@runflow-ai/sdk/observability'; message({ role: 'user', content: 'Quais acomodações?' }); message({ role: 'assistant', content: 'Oferecemos duas opções...' }); ``` ### When to use * **Custom workflows** (WhatsApp handlers, webhook routers) where you control the message flow without `agent.process()`. * **LLM agents** when you want to also expose the conversation as chat (wrap `agent.process()` calls). * **Anywhere** you want the execution to render as a conversation in the portal. If you never call `message()`, nothing changes — your existing traces and rendering keep working exactly as before. ### Wrapping an agent call (LLM) ```typescript theme={null} import { Agent, message, openai } from '@runflow-ai/sdk'; const agent = new Agent({ name: 'concierge', model: openai('gpt-4o'), instructions }); export async function main(input: AgentInput) { message({ role: 'user', content: input.message }); const reply = await agent.process({ message: input.message, sessionId: input.sessionId }); message({ role: 'assistant', content: reply.message }); return reply; } ``` ### Custom workflow (no LLM) ```typescript theme={null} import { message, startSpan, log } from '@runflow-ai/sdk/observability'; export async function handleWebhook(input: AgentInput) { const turn = startSpan('turn'); try { message({ role: 'user', content: input.message }); // "48337725826" const verify = startSpan('verify_cpf'); const result = await api.searchUser(input.message); log('cpf_verified', { output: result }); verify.end({}); const reply = result.user_exists ? `Bem-vindo de volta, ${result.name}!` : 'CPF não encontrado. Vamos te cadastrar! Qual seu nome?'; message({ role: 'assistant', content: reply }); return { message: reply }; } finally { turn.end({}); } } ``` The `startSpan` call is optional but recommended — it groups the technical traces under the turn so the drill-down drawer in the portal stays organized. ### Multiple assistant messages per turn A turn can emit any number of assistant messages — they render as consecutive bubbles in chronological order, exactly like WhatsApp: ```typescript theme={null} message({ role: 'user', content: 'Oi' }); message({ role: 'assistant', content: 'Oi! Tudo bem?' }); message({ role: 'assistant', content: 'Como posso te ajudar?' }); ``` ### Structured content (buttons, audio, image) `content` accepts a string OR an object with a `type` field. The portal renders text natively and falls back to a JSON view for structured content (buttons / media renderers are on the roadmap): ```typescript theme={null} message({ role: 'assistant', content: { type: 'buttons', text: 'O que deseja?', items: [ { id: 'support', label: 'Suporte' }, { id: 'sales', label: 'Vendas' }, ], }, }); ``` ### Hierarchy and grouping Messages follow the same parenting rules as `log()` and `startSpan()`: | You write | Where the message goes | | --------------------------------------------- | ----------------------------- | | `message({...})` inside a `startSpan()` block | Child of the active span | | `message({..., parentId: span.traceId })` | Child of that span explicitly | | `message({...})` with no active span | Root (no grouping) | In the portal, clicking any bubble of a turn opens the drill-down with all traces of that execution — the hierarchy you create only affects how the trace tree looks in the drawer. ### Parameters | Name | Type | Required | Description | | ------------------ | --------------------- | -------- | ------------------------------------------------------------------- | | `data.role` | `string` | Yes | `'user'`, `'assistant'`, `'system'`, `'tool'`, or any custom string | | `data.content` | `string \| object` | Yes | Text (renders natively) or structured object with a `type` field | | `data.metadata` | `Record` | No | Extra fields (citations, confidence, custom flags) | | `data.parentId` | `string` | No | Explicit parent span's `traceId` | | `options.parentId` | `string` | No | Same as `data.parentId` (positional override) | ## Business Event Tracking Use `track()` to emit custom business events from your agent. These events power the **Metrics** dashboard in the portal, where you can build KPI cards, charts, and real-time feeds without writing any backend code. ```typescript theme={null} import { track } from '@runflow-ai/sdk/observability'; // Inside your agent's tool or logic track('alert_received', { company: 'NW Telecom', severity: 'High', source: 'Zabbix', }); track('ticket_resolved', { duration: 45, answered: true, category: 'network', }); ``` Events are buffered and sent in batches automatically (up to 50 events or every 2 seconds). No manual flushing needed during normal execution. ### How It Works 1. Call `track(eventName, properties)` anywhere in your agent code 2. The SDK buffers events and sends them in batches to the Runflow API 3. Open the **Metrics** tab in the portal to create dashboard cards 4. Cards auto-discover your event names and properties -- no configuration needed ### Parameters | Parameter | Type | Required | Description | | ------------ | --------------------- | -------- | ------------------------------------------------------------- | | `eventName` | `string` | Yes | Name of the event (e.g. `'alert_received'`, `'order_placed'`) | | `properties` | `Record` | No | Key-value pairs with event data | | `options` | `TrackOptions` | No | Override `threadId`, `executionId`, or `timestamp` | ### Options ```typescript theme={null} track('payment_processed', { amount: 150.00 }, { threadId: 'custom-thread-123', // Override auto-resolved thread executionId: 'custom-exec-456', // Override auto-resolved execution timestamp: '2026-01-15T10:30:00Z', // Custom timestamp }); ``` ### Flushing Before Exit For short-lived scripts or CLI tools, call `flushTrackEvents()` before exiting to ensure all events are sent: ```typescript theme={null} import { track, flushTrackEvents } from '@runflow-ai/sdk/observability'; track('batch_completed', { total: 500, errors: 2 }); // Ensure events are sent before process exits await flushTrackEvents(); ``` ### Dashboard Cards In the portal, navigate to your agent's **Metrics** tab to create cards: * **Number** -- KPI with a single aggregated value (count, sum, avg) * **Rate** -- Percentage based on a filtered property value * **Line / Bar** -- Time-series charts grouped by hour, day, week, or month * **Pie** -- Distribution chart over time periods Cards support drag-to-resize, custom colors, and multiple aggregation types: | Aggregation | Description | Example | | ---------------- | ----------------------------------------- | ----------------------- | | `count` | Total events | Total alerts received | | `rate` | Percentage where property matches a value | % of alerts answered | | `sum` | Sum of a numeric property | Total revenue | | `avg` | Average of a numeric property | Average response time | | `distinct_count` | Count of unique property values | Unique companies served | ### Best Practices Use `snake_case` names that describe what happened: `alert_received`, `ticket_resolved`, `payment_processed`. Avoid generic names like `event` or `action`. Properties are stored as JSON and queried via keys. Flat key-value pairs work best for dashboard aggregations: ```typescript theme={null} // Good track('order_placed', { amount: 99.90, category: 'electronics', customer_id: 'c_123' }); // Avoid nested objects track('order_placed', { order: { amount: 99.90, details: { category: 'electronics' } } }); ``` Keep the same property as the same type across events. If `duration` is a number in one event, don't send it as a string in another -- aggregations like `sum` and `avg` rely on numeric values. ## Execution Reviews Available since `@runflow-ai/sdk@1.1.13`. Requires an API client that exposes the `reviews` namespace. Ownership/SLA fields (`assignedToUserId`, `dueAt`, `disposition`) and the `queue`/`source` filters require `1.5.1+`. `Reviews` exposes the execution-review feedback loop programmatically — the same surface used by the portal QA queue and the MCP tools (`create_execution_review`, `list_execution_reviews`, …). Use it from LLM-judge agents, KB curators, or scheduled jobs to flag bad executions, triage them, and feed corrected outputs back into training datasets. ```typescript theme={null} import { Reviews } from '@runflow-ai/sdk'; const reviews = new Reviews(); // Flag a bad execution — optionally with an owner and a deadline (SDK ≥ 1.5.1) const { reviewId } = await reviews.create({ executionId: 'exec-uuid', agentId: 'agent-uuid', rating: 'bad', comment: 'Bot gave the wrong business hours', priority: 'high', tags: ['hours_wrong'], assignedToUserId: 'user-uuid', // optional: who owns the fix dueAt: '2026-08-01T12:00:00Z', // optional: SLA deadline }); // Resolve it with a corrected answer (auto-stamps resolvedBy / resolvedAt) await reviews.update(reviewId, { status: 'resolved', actionTaken: 'knowledge_base_updated', correctedOutput: 'We are open 9–18, Monday to Friday.', disposition: 'fixed', // or 'false_positive' | 'duplicate' | 'no_action' }); ``` Each execution can have **only one** review — call `checkHasReview` first if you don't want a 409 surfaced to the caller. Authentication uses your `RUNFLOW_API_KEY`; `reviewedBy` is auto-populated from the API key label on the backend. ### Methods | Method | Description | | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `create(args)` | Create a review (executionId + agentId + rating + comment ≥ 10 chars). Optional `assignedToUserId`, `dueAt` and `source`. | | `checkHasReview(executionId)` | Idempotent check before `create()`. | | `list(filters?)` | List reviews — filter by `agentId`, `status`, `rating`, `priority`, `dateFrom/dateTo`, `search` (PT full-text), plus `queue` (`active` / `history`), `source` (`human` / `sdk` / `policy` / `legacy`) and `assignedToUserId`. | | `get(reviewId)` | Fetch a single review. | | `update(reviewId, args)` | Update status, `actionTaken`, `resolutionNotes`, `correctedOutput`, `assignedToUserId`, `dueAt`, `disposition`, etc. | | `delete(reviewId)` | Hard delete. | | `stats({ agentId })` | Aggregated counts and `avgResolutionHours`. | | `exportForTraining({ agentId, status?, rating? })` | Export resolved reviews as OpenAI-conversational fine-tuning examples. | ### Feedback loop pattern ```typescript theme={null} // Nightly job: pull resolved reviews into a training set const dataset = await reviews.exportForTraining({ agentId, status: 'resolved', }); // Each `dataset.training_examples[]` is an OpenAI conversational // example with `messages: [{ role, content }, …]` and metadata // (review_id, execution_id, rating, was_corrected). fs.writeFileSync('training.jsonl', dataset.training_examples .map((ex) => JSON.stringify(ex)) .join('\n')); ``` ### Errors `Reviews` throws typed errors so callers can branch cleanly: * `ReviewAlreadyExistsError` (HTTP 409) — the execution already has a review. * `ReviewNotFoundError` (HTTP 404) — the reviewId doesn't exist or belongs to another tenant. * `ReviewsError` (any other status) — generic error with `status` + `body`. ## Proactive review policies Portal feature: **Agent → Reviews → Policies**. Policies have no public API surface yet — the reviews they create flow through the same `Reviews` SDK/REST surface above, tagged with `source: 'policy'`. Instead of waiting for a human (or your own judge job) to flag a bad execution, **review policies** watch an agent's real conversations and open reviews automatically. A policy evaluates a conversation only after it goes quiet — a per-policy silence window, because in messaging channels there is no "end of execution"; time closes the episode. When it fires, it files an actionable review in the agent's queue (owner, priority-driven SLA deadline) and rings the in-app bell. **Two policy types:** | Type | What it catches | Cost | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | **Rules** | `agent_no_reply` — the customer's last message errored or got no answer. `customer_abandonment` — the agent spoke last and the customer never came back within the window. | Free — evaluated on 100% of quiet conversations. | | **LLM judge** | Free-form quality criteria (*"the agent must never promise discounts…"*), written inline or **linked to a platform Prompt** (the prompt's live content is used at evaluation time). | Runs on **your own LLM provider credential** (BYO), with per-policy sampling to control spend. | **Conversation grouping** is chosen per policy: * **Whole thread** — the entire thread is one conversation (in the SDK, the thread is derived from the customer identity). * **Sessions by time gap** — the customer's stream is sliced into separate conversations whenever the gap between messages exceeds a configurable window. Recommended for WhatsApp-style channels where a single customer accumulates a long-lived thread. **Judge extras:** * **Test before saving** — dry-run the criteria against the agent's latest real conversations straight from the policy editor (no reviews are created). * **Behavior analysis (opt-in)** — include each turn's connector/tool calls in the judge context (`[actions: patch-lead ✓, run-salesbot ✗]`) so it can evaluate what the agent *did*, not just what it replied. * **Health at a glance** — each policy row shows its last run, reviews created, evaluation errors and judge token spend over the last 7 days. ## Reading traces from the SDK (with pagination) The cross-agent SDK's `Executions.getDetails(executionId)` returns the same hierarchical trace tree the portal shows on the "execution detail" page — but bounded to protect the DB. See [Cross-Agent SDK → Executions](/core-concepts/cross-agent#executions) for the full surface. Two modes, depending on the caller: | Caller | Mode | Per-request limit | Why | | ----------------------------------------------------------------------- | --------- | ------------------------------- | ------------------------------------------------------------------------------------------------- | | Portal (`/api/v1/observability/executions/:id`) | Bulk | 10 000 (hard cap) | UI renders aggregations from the trace array. Cap is a safety net — normal traffic never hits it. | | SDK runtime (`/api/v1/runtime/v1/observability/executions/:id/details`) | Paginated | 500 default, 1 000 max per page | Forces SDK consumers to walk pages instead of pulling everything. | ```typescript theme={null} import { Executions } from '@runflow-ai/sdk/executions'; const executions = new Executions(); // Default: 500 traces per page, with pagination metadata const { execution, traces, tracesTotal, tracesHasMore } = await executions.getDetails(executionId); // Iterate every page without writing the loop yourself for await (const page of executions.iterateTraces(executionId, { pageSize: 500 })) { console.log(`offset=${page.tracesOffset} / total=${page.tracesTotal}`); for (const root of page.traces) audit(root); } ``` The portal behavior is fully backwards-compatible — the existing `ObservabilityController.getExecutionDetails` keeps returning the entire trace array, with the new pagination fields ignored. The 10 000 cap only kicks in if an execution actually generates that many traces (which would be a bug to investigate, not a regression). ## Observability Comparison | Feature | Agent | Workflow | Standalone (`log`) | | ---------------------------------- | ----------------------- | ----------------------- | ------------------------ | | Automatic tracing | Yes | Yes | Manual | | Mode (`full`/`standard`/`minimal`) | Yes | Yes | -- | | `onTrace` interceptor | Yes | Yes | `configureLogging()` | | Truncation control | Yes | Yes | -- | | Trace hierarchy | Automatic | Automatic (steps) | `startSpan` + `parentId` | | Chat rendering in portal | Via `message()` wrapper | Via `message()` wrapper | Via `message()` | | Default mode | `full` | `full` | -- | ## Next Steps Workflow tracing and step types Configure observability # Privacy (PII Sanitization) Source: https://docs.runflow.ai/core-concepts/privacy Automatically redact personal data from traces and logs before they are persisted The **privacy** module automatically sanitizes personally identifiable information (PII) from observability traces before they are stored. It works across all execution paths: standalone agents, multi-agent (supervisor), and workflows. ## Quick Start ```typescript theme={null} import { Agent, openai } from '@runflow-ai/sdk'; const agent = new Agent({ name: 'Support Agent', instructions: 'Help customers with their accounts.', model: openai('gpt-4o'), privacy: 'br', // That's it. One line. }); ``` With `privacy: 'br'`, every trace generated by this agent will have CPFs, emails, phone numbers, credit cards, and 30+ other PII patterns automatically redacted before being sent to the observability backend. ## Configuration Formats The `privacy` field accepts multiple formats depending on how much control you need: | Format | Description | Example | | ---------- | ------------------------------ | ------------------------------------------------ | | `true` | All locales, `redact` strategy | `privacy: true` | | `false` | Explicitly disabled | `privacy: false` | | `string` | Single locale | `privacy: 'br'` | | `string[]` | Multiple locales | `privacy: ['br', 'us']` | | `object` | Full `PrivacyConfig` | `privacy: { locales: ['br'], strategy: 'mask' }` | ## Locales Each locale adds a set of PII detection patterns. The `common` locale is **always included automatically**, even when you specify other locales. | Locale | Patterns Included | | -------- | ------------------------------------------------------------------------------------------ | | `common` | Email, credit card, JWT, Bearer token, AWS key, IPv4/v6, MAC address, date of birth | | `br` | CPF (with validation), CNPJ (with validation), RG, BR phone, CEP, PIS/PASEP, CNS, voter ID | | `us` | SSN (with validation), US phone, ZIP code, driver's license, passport | | `eu` | IBAN, EU phone, VAT number, NIF (Portugal), NIE (Spain) | ```typescript theme={null} // Brazilian company — catches CPF, CNPJ, BR phones, plus all common patterns privacy: 'br' // US + Brazil — catches SSN, CPF, phones from both countries, etc. privacy: ['br', 'us'] // International — all locales privacy: true ``` ## Redaction Strategies Control how PII values are replaced in traces: | Strategy | CPF Example | Email Example | Use Case | | ------------ | --------------------- | --------------------- | -------------------------------------- | | `'redact'` | `[REDACTED]` | `[REDACTED]` | **Default.** Maximum compliance | | `'mask'` | `***.***247-25` | `j***@company.com` | Debugging with partial data | | `'hash'` | `[HASH:a1b2c3d4e5f6]` | `[HASH:x9y8z7w6v5u4]` | Correlation without exposure | | `'category'` | `[CPF]` | `[EMAIL]` | Know the TYPE without seeing the value | | `function` | Custom | Custom | Full control | ```typescript theme={null} // Mask strategy — keeps partial data visible for debugging privacy: { locales: ['br'], strategy: 'mask', } // Custom strategy — different handling per category privacy: { locales: ['br'], strategy: (match, pattern) => { if (pattern.category === 'credential') return '[BLOCKED]'; return `<${pattern.label}>`; }, } ``` ## Full Configuration (PrivacyConfig) ```typescript theme={null} const agent = new Agent({ name: 'Compliance Agent', instructions: '...', model: openai('gpt-4o'), privacy: { // --- Basic --- enabled: true, // default: true locales: ['br'], // default: ['common'] strategy: 'redact', // default: 'redact' // --- Categories --- includeCategories: ['document', 'contact'], // only these (optional) excludeCategories: ['location'], // exclude these (optional) // --- Field name detection --- detectFields: true, // default: true sensitiveFields: ['matricula'], // additional custom fields allowFields: ['agent_id'], // fields to NEVER sanitize // --- Scope --- scope: { input: true, // default: true output: true, // default: true metadata: false, // default: false }, // --- Custom patterns --- customPatterns: [{ id: 'internal_id', label: 'Internal ID', category: 'document', pattern: /MAT-\d{8}/g, }], // --- Audit --- audit: true, // default: false onRedaction: (event) => { console.log(`PII found: ${event.patternId} at ${event.path}`); }, // --- Post-sanitization hook --- onSanitize: (data, context) => { // Additional logic after standard sanitization return data; }, }, }); ``` ## PII Categories | Category | What It Detects | Examples | | ------------ | ------------------- | ----------------------------------------- | | `document` | Identity documents | CPF, CNPJ, RG, SSN, passport | | `contact` | Contact information | Email, phone, WhatsApp | | `financial` | Financial data | Credit card, IBAN, bank account | | `location` | Location data | CEP, ZIP code | | `personal` | Personal data | Date of birth, name (via field detection) | | `network` | Network identifiers | IPv4, IPv6, MAC address | | `credential` | Credentials | Bearer token, API key, JWT, AWS key | | `health` | Health data | CNS, health plan ID | | `custom` | Custom patterns | Defined by you | ### Filter by Category ```typescript theme={null} // Only sanitize documents and contacts privacy: { locales: ['br'], includeCategories: ['document', 'contact'], } // Everything EXCEPT location privacy: { locales: ['br'], excludeCategories: ['location'], } ``` ## Field Name Detection Beyond regex patterns, the sanitizer detects PII by **JSON field names**. This catches sensitive data even when the value itself doesn't match any pattern (e.g., a name field containing "Maria Silva"). Works with all naming conventions: `snake_case`, `camelCase`, `kebab-case`. ### Built-in Sensitive Fields (60+) * **Documents:** `cpf`, `cnpj`, `rg`, `ssn`, `passport`, `cnh`, `pis`, ... * **Contact:** `email`, `phone`, `telefone`, `celular`, `whatsapp`, ... * **Names:** `nome`, `nome_completo`, `full_name`, `first_name`, `last_name`, ... * **Address:** `address`, `endereco`, `cep`, `logradouro`, `rua`, ... * **Financial:** `credit_card`, `card_number`, `bank_account`, `iban`, ... * **Health:** `cns`, `cartao_sus`, `health_plan`, `prontuario`, ... * **Credentials:** `password`, `senha`, `secret`, `token`, `api_key`, ... * **Birth:** `birth_date`, `data_nascimento`, `dob`, ... ### Compound Token Matching Compound field names are split into tokens and matched individually: | Field | Tokens | Match? | Reason | | --------------- | ------------------ | ------ | ------------------------------------------ | | `contactName` | `contact`, `name` | Yes | `name` in compound = PII | | `nome_contato` | `nome`, `contato` | Yes | `nome` = always PII | | `email_contato` | `email`, `contato` | Yes | `email` = always PII | | `name` (alone) | `name` | No | Ambiguous alone (could be tool/agent name) | | `agentId` | `agent`, `id` | No | No sensitive token | ### Custom Fields ```typescript theme={null} // Add custom sensitive fields privacy: { locales: ['br'], sensitiveFields: ['matricula', 'plano_odontologico'], } // Allow specific fields to never be sanitized privacy: { locales: ['br'], allowFields: ['agent_id', 'execution_id', 'trace_id'], } ``` ## Propagation in Multi-Agent and Workflows ### Multi-Agent (Supervisor Pattern) Configure privacy **once on the supervisor** — it automatically propagates to all child agents: ```typescript theme={null} const agent = new Agent({ name: 'Supervisor', instructions: 'Route requests.', model: openai('gpt-4o-mini'), privacy: 'br', // Configure HERE only agents: { qualifier: { name: 'Qualifier', instructions: '...', model: openai('gpt-4o') }, responder: { name: 'Responder', instructions: '...', model: openai('gpt-4o') }, }, }); ``` ``` Supervisor (privacy: 'br') |-- trace collector with privacy |-- Qualifier agent --> inherits collector |-- Responder agent --> inherits collector ``` Child agents **do not need** their own `privacy` config. ### Workflows ```typescript theme={null} const workflow = createWorkflow({ id: 'qualification', privacy: 'br', // Configure HERE only steps: [...], inputSchema: z.object({ message: z.string() }), outputSchema: z.object({ result: z.string() }), }); ``` ``` Workflow (privacy: 'br') |-- trace collector with privacy |-- Function step --> traces sanitized |-- Agent step --> inherits collector |-- Connector step --> traces sanitized ``` ## Standalone Usage (Without Agent/Workflow) Use the sanitizer directly for custom pipelines or data processing: ```typescript theme={null} import { createPIISanitizer } from '@runflow-ai/sdk'; const sanitizer = createPIISanitizer({ locales: ['br'], strategy: 'redact', }); // Sanitize a string sanitizer.sanitize('My CPF is 529.982.247-25'); // → 'My CPF is [REDACTED]' // Sanitize an object (deep traversal) sanitizer.sanitizeDeep({ customer: { nome: 'Maria Silva', cpf: '529.982.247-25', contact: { email: 'maria@test.com' }, }, }); // → { customer: { nome: '[REDACTED]', cpf: '[REDACTED]', contact: { email: '[REDACTED]' } } } ``` ## Audit and Compliance Track every redaction event for compliance reporting: ```typescript theme={null} const agent = new Agent({ name: 'Audited Agent', instructions: '...', model: openai('gpt-4o'), privacy: { locales: ['br'], audit: true, onRedaction: (event) => { // event.patternId = 'br_cpf' // event.category = 'document' // event.path = 'input.customer.cpf' // event.field = 'input' // event.originalLength = 14 // event.timestamp = Date saveToAuditLog(event); }, }, }); ``` ## Pattern Validation Some patterns include mathematical validation to reduce false positives: | Pattern | Validation | | ----------- | ---------------------------------------------- | | CPF | Check digits (mod 11) | | CNPJ | Check digits (mod 11 with weights) | | Credit Card | Luhn algorithm | | SSN | Cannot start with 000, 666, or 9xx | | IPv4 | Excludes common IPs (127.0.0.1, 0.0.0.0, etc.) | ## Safety Guarantees | Scenario | Behavior | | --------------------------- | ------------------------------------------ | | Circular reference in trace | Detected and replaced with `[Circular]` | | Date, Buffer, RegExp values | Preserved without modification | | Error with PII in message | Message sanitized, structure preserved | | Map, Set values | Traversed and sanitized | | Depth > 20 levels | Stops recursion, returns data as-is | | Internal sanitizer error | **Drops the entire trace** (fail closed) | | `privacy` not configured | Zero impact, identical behavior to default | The sanitizer follows a **fail-closed** security model: if something goes wrong during sanitization, the trace is dropped entirely rather than risk leaking PII. This is intentional — data safety over data availability. ## Known Limitations 1. **Names in free text**: Proper names inside message text (e.g., "Hello Maria") are **not detected** by regex. Names are only captured via **field name detection** (e.g., a field called `nome`, `contactName`). 2. **Numeric false positives**: Numeric sequences may match phone or ZIP patterns. Use `excludeCategories` or `allowFields` to tune. ## Next Steps Tracing and metrics that privacy protects Multi-agent systems with automatic privacy propagation Data pipelines with built-in PII protection Production tips for secure agents # Prompts Source: https://docs.runflow.ai/core-concepts/prompts Manage prompt templates with global and tenant-specific prompts The **Prompts** module manages prompt templates with support for global and tenant-specific prompts. ## Using `loadPrompt()` with Agent (Recommended) The simplest way to use prompts from the portal is with `loadPrompt()`. It works just like `openai()` - no await needed! ```typescript theme={null} import { Agent, openai, loadPrompt } from '@runflow-ai/sdk'; // Load prompt directly in agent config - no await! const agent = new Agent({ name: 'Support Agent', instructions: loadPrompt('customer-support', { product: 'CRM Pro', tone: 'professional', greeting: 'Hello' }), model: openai('gpt-4o'), }); // The prompt is resolved automatically when processing await agent.process({ message: 'I need help!' }); ``` **How it works:** 1. `loadPrompt()` creates a lazy reference (no API call yet) 2. When `agent.process()` runs, the prompt is fetched from the portal 3. Variables are rendered automatically 4. Result is cached for subsequent calls ```typescript theme={null} // Without variables instructions: loadPrompt('simple-prompt') // With variables (uses {{variable}} syntax in prompt content) instructions: loadPrompt('customer-support', { product: 'SaaS Platform', tone: 'friendly', language: 'Portuguese' }) ``` ## Standalone Prompts Manager For more control, use the `Prompts` class directly: ```typescript theme={null} import { Prompts } from '@runflow-ai/sdk'; const prompts = new Prompts(); // Get prompt (global or tenant-specific) const prompt = await prompts.get('sistema'); console.log(prompt.content); console.log('Is global?', prompt.isGlobal); // List all available prompts const allPrompts = await prompts.list({ limit: 50 }); allPrompts.forEach(p => { console.log(`${p.name} ${p.isGlobal ? '🌍' : '🏢'}`); }); // Create tenant-specific prompt const custom = await prompts.create( 'my-prompt', 'You are a specialist in {{topic}}.', { variables: ['topic'] } ); // Update tenant prompt await prompts.update('my-prompt', { content: 'You are a SENIOR specialist in {{topic}}.' }); // Delete tenant prompt await prompts.delete('my-prompt'); // Render template with variables const rendered = prompts.render( 'Hello {{name}}, welcome to {{company}}!', { name: 'John', company: 'Runflow' } ); // Get and render in one call const text = await prompts.getAndRender('my-prompt', { topic: 'AI' }); ``` ## Security Rules * ✅ Can read global prompts (provided by Runflow) * ✅ Can create/update/delete own tenant prompts * ❌ Cannot modify global prompts * ❌ Cannot access other tenants' prompts ## Next Steps Learn about agents Learn about RAG # RPA / Browser Automation Source: https://docs.runflow.ai/core-concepts/rpa Automate browser interactions with Playwright-powered tools **RPA (Robotic Process Automation)** lets your agents navigate websites, fill forms, click buttons, extract data, and download files using a real browser. Built on top of [Playwright](https://playwright.dev/). ## Installation Playwright is an optional peer dependency. Install it alongside the browser binaries: ```bash theme={null} npm install playwright npx playwright install chromium ``` Or use the CLI shortcut: ```bash theme={null} rf rpa install ``` Check your setup: ```bash theme={null} rf rpa status ``` ## Quick Start The simplest way to use RPA is with `createBrowserTool` — it manages the browser lifecycle automatically. ```typescript theme={null} import { createBrowserTool } from '@runflow-ai/sdk/rpa'; import { z } from 'zod'; const scrapeTool = createBrowserTool({ id: 'scrape-products', description: 'Scrape product listings from a website', inputSchema: z.object({ url: z.string().url(), }), browser: { headless: true, screenshotsDir: './screenshots', }, execute: async ({ context, browser }) => { const page = browser.page; await page.goto(context.url); const products = await page.$$eval('.product', (els) => els.map((el) => ({ name: el.querySelector('.name')?.textContent?.trim(), price: el.querySelector('.price')?.textContent?.trim(), })) ); await browser.screenshot('products-page'); return { products }; }, }); ``` Then add it to your agent: ```typescript theme={null} import { Agent } from '@runflow-ai/sdk'; const agent = new Agent({ name: 'scraper', instructions: 'You scrape product data from websites.', model: openai('gpt-4o'), tools: { scrapeTool }, }); ``` ## createBrowserTool Factory function that wraps your browser logic into a Runflow tool with automatic lifecycle management. ```typescript theme={null} import { createBrowserTool } from '@runflow-ai/sdk/rpa'; const myTool = createBrowserTool({ id: 'tool-id', description: 'What this tool does (shown to LLM)', inputSchema: z.object({ /* ... */ }), outputSchema: z.object({ /* ... */ }), // optional browser: { headless: true, viewport: { width: 1440, height: 900 }, timeout: 30000, screenshotsDir: './screenshots', }, execute: async ({ context, browser, projectId, companyId, userId, sessionId }) => { const page = browser.page; // your automation logic return { /* result */ }; }, }); ``` **What it handles for you:** * Launches the browser before your `execute` runs * Closes the browser after (even on errors) * Takes an error screenshot automatically if `screenshotsDir` is configured * Attaches RPA trace data to the output for observability * Validates input/output with Zod schemas ### Browser Configuration | Option | Type | Default | Description | | ------------------ | ------------------------ | ---------- | ------------------------------------------------ | | `headless` | `boolean` | `true` | Run browser without visible window | | `viewport` | `{ width, height }` | `1440x900` | Browser viewport size | | `timeout` | `number` | `30000` | Default timeout in milliseconds | | `acceptDownloads` | `boolean` | `true` | Allow file downloads | | `slowMo` | `number` | - | Slow down actions by N ms (useful for debugging) | | `screenshotsDir` | `string` | - | Directory to save screenshots | | `userAgent` | `string` | - | Custom user agent string | | `locale` | `string` | - | Browser locale (e.g., `pt-BR`) | | `timezoneId` | `string` | - | Timezone (e.g., `America/Sao_Paulo`) | | `extraHTTPHeaders` | `Record` | - | Custom HTTP headers | | `launchArgs` | `string[]` | - | Extra Chromium launch arguments | ## BrowserSession For more control, use `BrowserSession` directly. This is useful when you need multiple pages, custom lifecycle, or manual tracing. ```typescript theme={null} import { BrowserSession } from '@runflow-ai/sdk/rpa'; const session = new BrowserSession({ headless: true, viewport: { width: 1920, height: 1080 }, screenshotsDir: './screenshots', }); await session.launch(); const page = session.page; await page.goto('https://example.com'); // Take a screenshot await session.screenshot('home-page'); // Traced action (appears in observability) const title = await session.traced('get-title', async () => { return page.title(); }); await session.close(); ``` ### Key Methods | Method | Description | | ----------------------------------------- | ---------------------------------------------- | | `launch(config?)` | Start the browser | | `close()` | Close browser and cleanup | | `screenshot(name)` | Take a full-page screenshot, returns file path | | `waitForNavigation(urlPattern, timeout?)` | Wait for URL to match a RegExp | | `waitForSelector(selector, timeout?)` | Wait for a CSS selector to appear | | `getContent()` | Get page HTML content | | `newPage()` | Open a new page/tab | | `traced(action, fn, meta?)` | Wrap an operation in a traced span | ### Properties | Property | Type | Description | | ------------- | -------------------------- | ------------------------------------ | | `page` | `Page` | Current Playwright page | | `isLaunched` | `boolean` | Whether the browser is running | | `artifacts` | `BrowserSessionArtifact[]` | Screenshots, downloads, PDFs created | | `actionSpans` | `BrowserActionSpan[]` | All traced actions | ### Observability Every `BrowserSession` tracks actions and artifacts. Get a summary with: ```typescript theme={null} const trace = session.getTraceSummary(); // { // totalActions: 5, // totalDurationMs: 3200, // actions: [{ action: 'login', durationMs: 1200, ... }, ...], // artifacts: [{ type: 'screenshot', name: 'home', path: '...', ... }], // errors: [] // } ``` When using `createBrowserTool`, this trace is automatically attached to the tool output as `_rpaTrace`. ## High-Level Actions Helper functions for common browser patterns. Import from `@runflow-ai/sdk/rpa`. ### login Automate login flows with smart field detection. ```typescript theme={null} import { login } from '@runflow-ai/sdk/rpa'; await login(page, { url: 'https://app.example.com/login', username: 'user@example.com', password: 'secret123', waitAfterLogin: /dashboard/, // wait until URL matches }); ``` **Options:** | Option | Type | Default | Description | | ------------------ | ------------------ | ----------- | ----------------------------------- | | `url` | `string` | required | Login page URL | | `username` | `string` | required | Username/email value | | `password` | `string` | required | Password value | | `usernameSelector` | `string` | auto-detect | CSS selector for username field | | `passwordSelector` | `string` | auto-detect | CSS selector for password field | | `submitSelector` | `string` | auto-detect | CSS selector for submit button | | `waitAfterLogin` | `RegExp \| string` | - | URL pattern to wait for after login | | `timeout` | `number` | `30000` | Timeout in ms | Auto-detection works for most login pages. It finds the first text input for username, the password input, and the submit button by common labels (`Login`, `Entrar`, `Sign In`, etc.). ### fillForm Fill multiple form fields with flexible locators. ```typescript theme={null} import { fillForm } from '@runflow-ai/sdk/rpa'; await fillForm(page, [ { selector: '#name', value: 'John Doe' }, { label: 'Email', value: 'john@example.com' }, { role: 'combobox', label: 'Country', value: 'Brazil', type: 'select' }, { selector: '#terms', value: 'true', type: 'check' }, ]); ``` **FormField options:** | Option | Type | Description | | ---------- | -------------------------------------------- | --------------------------------------------------- | | `selector` | `string` | CSS selector | | `role` | `string` | Aria role (`textbox`, `combobox`, `checkbox`, etc.) | | `label` | `string` | Accessible label or placeholder | | `nth` | `number` | Index when multiple elements match | | `value` | `string` | Value to set | | `type` | `'fill' \| 'select' \| 'check' \| 'uncheck'` | Action type (default: `fill`) | Locator priority: `selector` > `role + label` > `role` > `label`. ### clickButton Click a button by its visible label. ```typescript theme={null} import { clickButton } from '@runflow-ai/sdk/rpa'; await clickButton(page, 'Submit'); ``` ### waitAndClick Wait for an element to appear, then click it. ```typescript theme={null} import { waitAndClick } from '@runflow-ai/sdk/rpa'; await waitAndClick(page, '.modal-confirm-button', 5000); ``` ### extractTable Extract an HTML table into structured data. ```typescript theme={null} import { extractTable } from '@runflow-ai/sdk/rpa'; const rows = await extractTable(page, 'table.results'); // [ // { "Name": "Product A", "Price": "$10", "Stock": "42" }, // { "Name": "Product B", "Price": "$25", "Stock": "7" }, // ] ``` Returns an array of objects where keys are column headers. ### extractText Extract text content from matching elements. ```typescript theme={null} import { extractText } from '@runflow-ai/sdk/rpa'; const titles = await extractText(page, 'h2.title'); // ["First Title", "Second Title", "Third Title"] ``` ### downloadFile Click an element to trigger a download and wait for it to complete. ```typescript theme={null} import { downloadFile } from '@runflow-ai/sdk/rpa'; const filePath = await downloadFile(page, '#export-btn', './downloads'); // "./downloads/report.xlsx" ``` ### screenshotPage Take a full-page screenshot. ```typescript theme={null} import { screenshotPage } from '@runflow-ai/sdk/rpa'; const path = await screenshotPage(page, './screenshots/page.png'); ``` ### waitForResponse Wait for a network response matching a URL pattern. ```typescript theme={null} import { waitForResponse } from '@runflow-ai/sdk/rpa'; const response = await waitForResponse(page, /api\/products/, 10000); ``` ## Full Example: CRM Login + Data Extraction ```typescript theme={null} import { Agent } from '@runflow-ai/sdk'; import { openai } from '@runflow-ai/sdk/models'; import { createBrowserTool, login, extractTable } from '@runflow-ai/sdk/rpa'; import { z } from 'zod'; const crmScrapeTool = createBrowserTool({ id: 'crm-contacts', description: 'Login to CRM and extract contact list', inputSchema: z.object({ searchTerm: z.string().describe('Term to search in CRM'), }), browser: { headless: true, screenshotsDir: './screenshots', locale: 'pt-BR', timezoneId: 'America/Sao_Paulo', }, execute: async ({ context, browser }) => { const page = browser.page; // 1. Login await browser.traced('login', () => login(page, { url: 'https://crm.example.com/login', username: process.env.CRM_USER!, password: process.env.CRM_PASS!, waitAfterLogin: /contacts/, }) ); // 2. Search await browser.traced('search', async () => { await page.fill('#search-input', context.searchTerm); await page.click('#search-button'); await page.waitForSelector('table.contacts'); }); // 3. Extract data const contacts = await browser.traced('extract', () => extractTable(page, 'table.contacts') ); await browser.screenshot('results'); return { contacts, total: contacts.length }; }, }); const agent = new Agent({ name: 'crm-agent', instructions: 'You extract contact data from the CRM system.', model: openai('gpt-4o'), tools: { crmScrapeTool }, }); export default agent; ``` ## Agent-Level RPA Config You can also configure RPA at the agent level: ```typescript theme={null} const agent = new Agent({ name: 'scraper', instructions: '...', model: openai('gpt-4o'), tools: { scrapeTool }, rpa: { enabled: true, browser: { headless: true, viewport: { width: 1440, height: 900 }, }, screenshotOnError: true, artifactsDir: './rpa-artifacts', }, }); ``` | Option | Type | Default | Description | | -------------------- | ---------------------- | ------- | ------------------------------------ | | `enabled` | `boolean` | `false` | Enable RPA capability for this agent | | `browser` | `BrowserSessionConfig` | - | Default browser config for all tools | | `maxConcurrentPages` | `number` | - | Limit concurrent browser pages | | `screenshotOnError` | `boolean` | `false` | Auto-screenshot on errors | | `artifactsDir` | `string` | - | Directory for all RPA artifacts | Agents with RPA tools are automatically detected during deploy and receive the `rpa` capability flag. This routes them to RPA-enabled workers with Chromium pre-installed. ## Debugging Use `slowMo` and `headless: false` during development to watch the browser: ```typescript theme={null} const tool = createBrowserTool({ // ... browser: { headless: false, slowMo: 500, // 500ms delay between actions screenshotsDir: './debug-screenshots', }, // ... }); ``` Use `rf test` to run your agent locally with a visible browser. # Schedule Source: https://docs.runflow.ai/core-concepts/schedule Create and manage scheduled executions programmatically or let agents schedule themselves **Schedule** lets you create timed executions for your agents — from code or from the agent itself during a conversation. When a user says *"remind me tomorrow at 9am"*, the agent can create that schedule automatically using built-in tools. ## Quick Start: Agent with Schedule Give your agent the ability to create schedules: ```typescript theme={null} import { Agent, openai, createScheduleTools } from '@runflow-ai/sdk'; const scheduleTools = createScheduleTools(); const agent = new Agent({ name: 'Assistant', instructions: 'You are a helpful assistant. You can schedule reminders.', model: openai('gpt-4o'), tools: { create_schedule: scheduleTools.create_schedule, }, }); ``` Now the agent handles conversations like: > **User:** "Me chama amanha as 9h pra eu enviar o relatorio" > > **Agent:** *uses `create_schedule` tool* — Schedule "Lembrete relatorio" created. I'll remind you tomorrow at 09:00. ## What `createScheduleTools()` Returns This function returns an object with **4 independent tools**. You choose which ones to give your agent — **don't spread all tools blindly**. ### Choosing the Right Tools `createScheduleTools()` returns: | Tool Key | What It Does | | ----------------- | --------------------------------- | | `create_schedule` | Creates a new schedule | | `list_schedules` | Lists all schedules for the agent | | `update_schedule` | Updates an existing schedule | | `cancel_schedule` | Cancels (deletes) a schedule | Pick only the tools your agent actually needs: ```typescript theme={null} const scheduleTools = createScheduleTools(); // Most common: agent that creates reminders tools: { create_schedule: scheduleTools.create_schedule, } // Agent that creates and can cancel its own reminders tools: { create_schedule: scheduleTools.create_schedule, cancel_schedule: scheduleTools.cancel_schedule, } // Internal admin agent with full control tools: { ...createScheduleTools(), // All 4 tools } ``` **Security: schedules are scoped per agent, not per user.** The `list_schedules` tool returns **all active schedules for the agent** — including schedules created by other users. If user A creates a reminder and user B asks "what are my reminders?", the agent will show user A's schedules too. Only give `list_schedules`, `update_schedule`, and `cancel_schedule` to agents where this is acceptable (e.g., internal admin bots, single-user agents, or agents where all users share the same context). For multi-user agents, prefer giving only `create_schedule`. ### Recommended Patterns | Scenario | Tools to Include | | ------------------------------------- | -------------------------------------------------------- | | Multi-user agent (customers, support) | `create_schedule` only | | Single-user personal assistant | `create_schedule` + `cancel_schedule` | | Internal team bot (shared context) | `create_schedule` + `list_schedules` + `cancel_schedule` | | Admin/monitoring agent | All 4 (`...createScheduleTools()`) | ### Tool Details ### `create_schedule` Creates a new scheduled execution. The LLM fills in the parameters based on the user's request. **Input schema:** | Parameter | Type | Required | Description | | --------------- | --------------------------------- | ------------------- | ------------------------------------------------ | | `name` | `string` | Yes | Descriptive name (e.g., "Daily report reminder") | | `type` | `'interval' \| 'daily' \| 'cron'` | Yes | Schedule type | | `interval` | `number` | For `interval` type | Minutes between each execution (1-43200) | | `time` | `string` | For `daily` type | Time in HH:MM format, 24-hour (e.g., "09:00") | | `cron` | `string` | For `cron` type | Cron expression (e.g., "0 9 \* \* 1-5") | | `timezone` | `string` | No | IANA timezone (default: "UTC") | | `message` | `string` | Yes | The message sent to the agent on each execution | | `maxExecutions` | `number` | No | Max times to fire (omit = unlimited) | **Returns:** `"Schedule created successfully. ID: {id}, Name: {name}, Type: {type}, Status: {status}"` ### `list_schedules` Lists all active schedules for the current agent. Takes no parameters. **Returns:** A formatted list of all schedules with ID, name, type, status, and message — or `"No schedules found."` ### `update_schedule` Updates an existing schedule. Only the fields you provide are changed. **Input:** `id` (required) + any field from `create_schedule` (optional). **Returns:** `"Schedule updated successfully. ID: {id}, Name: {name}, Status: {status}"` ### `cancel_schedule` Permanently deletes a schedule. **Input:** `id` (required). **Returns:** `"Schedule {id} has been cancelled and deleted successfully."` The LLM decides which tool to call based on the conversation. You don't need to write any routing logic — the tool descriptions guide the model. For example, when a user says "change my reminder to 10am", the LLM will call `list_schedules` first to find the ID, then `update_schedule` with the new time. ## How It Works End-to-End Understanding the full lifecycle is important. Here's what happens from creation to execution: ``` 1. User says "Remind me daily at 9am to check tickets" | 2. Agent LLM decides to call create_schedule tool | 3. SDK sends POST /runtime/v1/schedules to Runflow API | 4. API creates a SCHEDULER trigger in the trigger engine | 5. Trigger engine checks every minute for due triggers | 6. At 9:00 AM, trigger fires → HTTP POST to agent endpoint | 7. Agent receives the message and processes it | 8. Agent generates a response (sent via configured channel) ``` ### Step-by-step breakdown **Creation (steps 1-4):** When the agent calls `create_schedule`, the SDK sends the configuration to the Runflow API. The API creates a `SCHEDULER` trigger in the database with the cron expression, next run time, and the message payload. **Scheduling (step 5):** The Runflow trigger engine runs a periodic job (every minute) that queries the database for triggers where `nextRun <= now`. This is powered by the trigger-engine service. **Execution (steps 6-7):** When a trigger is due, the trigger engine sends an HTTP POST to the agent's endpoint with a payload containing the schedule data. The agent processes the `message` field as if it were a new conversation input. **Response (step 8):** The agent's response is delivered through the configured channel (API response, webhook, WhatsApp, etc.) depending on how the agent is deployed. ## What the Agent Receives When a Schedule Fires When a scheduled trigger fires, your agent receives an `AgentInput` with the schedule's `message` as the input, plus metadata about the trigger: ```typescript theme={null} // This is what arrives to your agent's process() method { message: "Check for new support tickets and summarize", // The 'message' from schedule.create() metadata: { triggerId: "trig_abc123", // Trigger ID triggerName: "Check Tickets", // Schedule name executedAt: "2026-03-20T09:00:00Z", // When it fired } } ``` This means: * **The `message` field** is what drives the agent's behavior. Write it as if you were sending a chat message to the agent. The agent processes it with all its tools, RAG, and memory — just like a regular conversation. * **The `metadata`** lets you know this was a scheduled execution (not a user message), which is useful for logging or conditional logic in tools. ### Example: Agent Reacting to a Scheduled Message ```typescript theme={null} const agent = new Agent({ name: 'Report Bot', instructions: `You generate daily reports. When you receive a scheduled message, execute the task described in it. Use the generate-report tool to create the report.`, model: openai('gpt-4o'), tools: { ...createScheduleTools(), generateReport: reportTool, }, }); // User creates the schedule during conversation await agent.process({ message: 'Schedule a daily sales report at 8am', sessionId: 'user_123', }); // Agent calls create_schedule with message: "Generate the daily sales report and send a summary" // Next day at 8:00 AM, the agent receives: // { message: "Generate the daily sales report and send a summary" } // Agent calls generateReport tool → produces the report ``` ## Conversation Context (Automatic) When an agent creates a schedule during a conversation where `identify()` is active, the SDK **automatically captures** the conversation context (entityType, entityValue, sessionId) and stores it in the trigger. When the schedule fires, this context is injected back into the agent input — so memory loads and the agent resumes the conversation. ```typescript main.ts theme={null} import { identify } from '@runflow-ai/sdk'; import { agent } from './agent'; export async function main(input: any) { // Identify the user — auto-detects phone, email, etc. const phone = input.entityValue || input.metadata?.phone; if (phone) { identify(phone); } // Same flow for regular messages, scheduled callbacks, and follow-ups return agent.process(input); } ``` ``` Lead: "Me liga amanha meio dia" → Agent calls create_schedule (maxExecutions: 1) → SDK captures current identify() state automatically: { entityType: 'phone', entityValue: '+5511999999999' } → Stored in trigger metadata Tomorrow at 12:00: → Trigger fires with entityType/entityValue in the input → main() calls identify('+5511999999999') → agent.process() loads memory for phone:+5511999999999 → Agent has full conversation history, resumes naturally → Schedule auto-deactivates (maxExecutions: 1 reached) ``` No extra code from the developer. The `create_schedule` tool captures and the trigger engine restores — the agent doesn't know the difference between a live message and a scheduled callback. **One-time vs recurring:** When the LLM creates a schedule from a request like "call me tomorrow", it must set `maxExecutions: 1` — otherwise the schedule fires every day forever. The tool description guides the LLM to do this, but you should also reinforce it in your agent's instructions: ``` instructions: `... When creating a one-time reminder, ALWAYS set maxExecutions to 1. Only omit maxExecutions for recurring tasks the user explicitly asked to repeat.` ``` See the [SDR Agent with Scheduled Follow-ups](/use-cases/sdr-follow-up) use case for a complete working example with lead qualification, scheduled callbacks, and inactive lead follow-ups. ## Programmatic API Use the `schedule` object directly in your code — useful for creating schedules in workflows, during deployment, or from external triggers: ### Create a Schedule ```typescript theme={null} import { schedule } from '@runflow-ai/sdk'; // Daily at 9am await schedule.create({ name: 'Daily Report', type: 'daily', time: '09:00', timezone: 'America/Sao_Paulo', message: 'Generate the daily sales report', }); // Every 2 hours await schedule.create({ name: 'Check Tickets', type: 'interval', interval: 120, // minutes message: 'Check for new support tickets and summarize', }); // Cron expression await schedule.create({ name: 'Weekly Summary', type: 'cron', cron: '0 9 * * MON', timezone: 'America/Sao_Paulo', message: 'Generate the weekly performance summary', }); // One-time reminder await schedule.create({ name: 'Send proposal', type: 'daily', time: '14:00', timezone: 'America/Sao_Paulo', message: 'Remind the user to send the proposal to client X', maxExecutions: 1, // Runs once, then auto-deactivates }); ``` ### List, Update, Cancel ```typescript theme={null} // List all schedules for this agent const schedules = await schedule.list(); for (const s of schedules) { console.log(`${s.name} - ${s.type} - ${s.status}`); } // Update timing await schedule.update('schedule-id', { time: '10:00', // Change from 9am to 10am }); // Cancel permanently await schedule.cancel('schedule-id'); ``` ## Schedule Types ### Interval Runs every N minutes, starting from the moment it's created: ```typescript theme={null} { type: 'interval', interval: 60, // Every hour (range: 1-43200 minutes = 30 days) } ``` ### Daily Runs at a specific time every day in the given timezone: ```typescript theme={null} { type: 'daily', time: '09:00', // HH:MM format, 24-hour timezone: 'America/Sao_Paulo', } ``` ### Cron Full cron expression support for advanced scheduling: ```typescript theme={null} { type: 'cron', cron: '0 9 * * MON-FRI', // Weekdays at 9am timezone: 'America/Sao_Paulo', } ``` Common cron patterns: | Expression | Description | | ----------------- | ------------------------------------ | | `0 9 * * *` | Every day at 9am | | `0 9 * * MON-FRI` | Weekdays at 9am | | `0 9 * * MON` | Every Monday at 9am | | `0 */2 * * *` | Every 2 hours | | `0 9,18 * * *` | At 9am and 6pm | | `*/30 * * * *` | Every 30 minutes | | `0 0 1 * *` | First day of every month at midnight | ## The `message` Field: Designing Good Schedules The `message` is the most important field — it's literally what the agent "hears" when the schedule fires. Write it as a clear instruction: ```typescript theme={null} // Good: specific, actionable await schedule.create({ name: 'Daily Sales Report', type: 'daily', time: '08:00', message: 'Generate the daily sales report for yesterday. Include total revenue, number of deals closed, and top 3 products by volume.', }); // Good: context-rich for the agent await schedule.create({ name: 'Ticket Triage', type: 'interval', interval: 120, message: 'Check for new unassigned support tickets. For each ticket, classify priority (P1-P4) and suggest an assignee based on the ticket category.', }); // Bad: too vague await schedule.create({ name: 'Report', type: 'daily', time: '08:00', message: 'Do the report', // Agent doesn't know what report or what to include }); ``` Think of `message` as the system prompt for that specific execution. The more context you give, the better the agent performs. ## Using in Workflows Combine schedules with workflows to create automated follow-up sequences: ```typescript theme={null} import { flow, schedule } from '@runflow-ai/sdk'; const onboardingFlow = flow('customer-onboarding') .step('welcome', { agent: welcomeAgent, prompt: ({ input }) => `Welcome the new customer: ${input.customerName}`, }) .step('schedule-followup', async ({ input, results }) => { await schedule.create({ name: `Follow-up: ${input.customerName}`, type: 'daily', time: '10:00', timezone: 'America/Sao_Paulo', message: `Check in with ${input.customerName} about their onboarding progress. Ask if they need help with setup.`, maxExecutions: 5, // Follow up for 5 days, then stop }); return { scheduled: true }; }) .build(); ``` ## Configuration Reference | Parameter | Type | Required | Default | Description | | --------------- | --------------------------------- | -------------- | --------------- | ------------------------------------------------------------------------------------------------------------- | | `name` | `string` | Yes | — | Schedule name (shown in list and traces) | | `type` | `'interval' \| 'daily' \| 'cron'` | Yes | — | Schedule type | | `interval` | `number` | For `interval` | — | Minutes between executions (1-43200) | | `time` | `string` | For `daily` | — | Time in HH:MM format (24-hour) | | `cron` | `string` | For `cron` | — | Standard 5-field cron expression | | `timezone` | `string` | No | `UTC` | IANA timezone (e.g., `America/Sao_Paulo`) | | `message` | `string` | Yes | — | Message delivered to the agent on each execution | | `maxExecutions` | `number` | No | `0` (unlimited) | Max number of executions. Use `1` for one-time reminders. Schedule auto-deactivates after reaching the limit. | | `metadata` | `object` | No | — | Custom key-value data stored with the schedule | ## Schedule Isolation and Security Schedules are **scoped to the agent, not to the user**. This has important implications: * An agent can only see schedules belonging to itself (not other agents). This is enforced at the API level using the agent ID from the SDK authentication context. * **All users of the same agent share the same schedule pool.** If user A creates a daily reminder and user B calls `list_schedules`, user B will see user A's reminder. * `update_schedule` and `cancel_schedule` can modify **any schedule** on that agent — including schedules created by other users. ### Best Practices 1. **For multi-user agents** (customer-facing bots, support agents): only include `create_schedule`. Don't expose `list_schedules`, `update_schedule`, or `cancel_schedule` — a user could see or delete another user's schedules. 2. **For single-user or internal agents**: you can safely include all tools since there's no cross-user risk. 3. **For admin agents**: use all tools with `observability: 'full'` to track who creates, modifies, or cancels schedules. ```typescript theme={null} // Safe for multi-user: create only, no listing or cancelling const scheduleTools = createScheduleTools(); const customerAgent = new Agent({ name: 'Customer Bot', instructions: 'Help customers set reminders. You can create schedules but cannot list or cancel existing ones.', model: openai('gpt-4o'), tools: { create_schedule: scheduleTools.create_schedule, }, }); ``` ## Next Steps Give agents internet search capabilities Connect to external APIs Track schedule executions and metrics Create custom tools for scheduled tasks # Supervisor (Multi-Agent) Source: https://docs.runflow.ai/core-concepts/supervisor Orchestrate multiple specialized agents with automatic LLM-based routing The **Supervisor pattern** lets you build multi-agent systems where a parent agent automatically routes requests to specialized child agents. Instead of one agent handling everything, you decompose complex domains into focused specialists — each with its own tools, knowledge base, and instructions. ## How It Works When you add the `agents` field to an Agent config, the parent becomes a supervisor. It uses an LLM call to analyze the user's message against each child agent's instructions and selects the best match. ``` User message arrives | Supervisor (LLM analyzes intent) | Which agent fits best? / | \ Agent A Agent B Agent C (tools) (RAG) (tools+RAG) | Response returned ``` The routing happens in a single LLM call — the supervisor reads each agent's `name` and `instructions`, compares them to the input, and picks one. If the LLM returns an invalid agent name, Runflow falls back to the first agent in the list. ## Basic Setup ```typescript theme={null} import { Agent, openai } from '@runflow-ai/sdk'; const agent = new Agent({ name: 'Customer Service', instructions: `Route requests to the right specialist: - Sales: pricing, plans, purchases, demos - Support: technical issues, bugs, how-to questions - Billing: invoices, payments, refunds`, model: openai('gpt-4o-mini'), // Cheap model for routing agents: { sales: { name: 'Sales Agent', instructions: 'Handle sales inquiries. Be consultative, not pushy.', model: openai('gpt-4o'), }, support: { name: 'Support Agent', instructions: 'Solve technical problems step by step.', model: openai('gpt-4o'), }, billing: { name: 'Billing Agent', instructions: 'Handle invoices, payments, and refund requests.', model: openai('gpt-4o'), }, }, }); const result = await agent.process({ message: 'I need a refund for my last invoice', sessionId: 'session_abc', }); // Supervisor routes to billing agent automatically ``` ## Specialist Agents with Tools and RAG Each child agent can have its own tools, RAG configuration, memory, and model — completely independent from other agents: ```typescript theme={null} import { Agent, openai, anthropic, createTool } from '@runflow-ai/sdk'; import { z } from 'zod'; const searchOrders = createTool({ id: 'search-orders', description: 'Search orders by ID or email', inputSchema: z.object({ orderId: z.string().optional(), email: z.string().email().optional(), }), execute: async (params) => { const res = await fetch(`https://api.example.com/orders?id=${params.orderId || ''}`); return res.json(); }, }); const checkInvoice = createTool({ id: 'check-invoice', description: 'Look up invoice details', inputSchema: z.object({ invoiceId: z.string(), }), execute: async (params) => { const res = await fetch(`https://api.example.com/invoices/${params.invoiceId}`); return res.json(); }, }); const supervisor = new Agent({ name: 'Router', instructions: 'Analyze intent and route to the correct specialist.', model: openai('gpt-4o-mini'), agents: { support: { name: 'Technical Support', instructions: 'Resolve technical issues. Search the knowledge base first.', model: openai('gpt-4o'), tools: { searchOrders }, rag: { vectorStore: 'support-docs', k: 5, threshold: 0.7, }, }, billing: { name: 'Billing Specialist', instructions: 'Handle billing inquiries. Always verify account first.', model: anthropic('claude-sonnet-4-20250514'), tools: { checkInvoice }, }, sales: { name: 'Sales Consultant', instructions: 'Help with plans, pricing, and demos. Be consultative.', model: openai('gpt-4o'), }, }, // Memory shared across the supervisor session memory: { maxTurns: 30, summarizeAfter: 20, }, // Full observability for all routing + agent execution observability: 'full', }); ``` ## Cost Optimization: Cheap Router, Quality Specialists The supervisor only classifies intent — it doesn't generate user-facing responses. Use a fast, cheap model for routing and reserve powerful models for the specialists that do the real work: ```typescript theme={null} const agent = new Agent({ name: 'Supervisor', instructions: 'Route to the appropriate department.', model: openai('gpt-4o-mini'), // ~$0.15/1M tokens - routing only agents: { analyst: { name: 'Data Analyst', instructions: 'Analyze data, generate reports, create visualizations.', model: anthropic('claude-sonnet-4-20250514'), // Quality for analysis }, writer: { name: 'Content Writer', instructions: 'Write marketing copy, blog posts, email campaigns.', model: openai('gpt-4o'), // Quality for writing }, coder: { name: 'Code Assistant', instructions: 'Help with code generation, debugging, code review.', model: anthropic('claude-sonnet-4-20250514'), // Quality for code }, }, }); ``` This pattern can reduce costs by 50-80% compared to using a single powerful model for everything. The supervisor call is fast and cheap — the expensive model only runs for the task that actually needs it. ## Routing Logic in Detail The supervisor builds a prompt like this internally: ``` Available agents: - support: Resolve technical issues. Search the knowledge base first. - billing: Handle billing inquiries. Always verify account first. - sales: Help with plans, pricing, and demos. Be consultative. User input: "I need a refund for my last invoice" Which agent should handle this? Respond with just the agent name. ``` The supervisor's **own instructions** are used as the system prompt, so you can add domain-specific routing rules: ```typescript theme={null} const supervisor = new Agent({ name: 'Healthcare Router', instructions: `Route patient requests to specialists. ## Routing Rules - **triage**: symptoms, emergencies, "I feel sick" - **appointments**: scheduling, rescheduling, cancellations - **records**: medical history, test results, prescriptions - **billing**: insurance claims, copays, payment plans ## Special Rules - If the patient mentions chest pain or breathing issues, ALWAYS route to triage - Prescription refills go to records, not appointments - Insurance questions go to billing, even if mentioned alongside appointments`, model: openai('gpt-4o-mini'), agents: { triage: { /* ... */ }, appointments: { /* ... */ }, records: { /* ... */ }, billing: { /* ... */ }, }, }); ``` ## Shared Memory and Context Memory is configured on the supervisor and shared across the entire session. If a customer starts with support and then asks about billing, the billing agent has full context of what was discussed: ```typescript theme={null} const supervisor = new Agent({ name: 'Support Hub', instructions: 'Route to the right team.', model: openai('gpt-4o-mini'), agents: { /* specialists */ }, memory: { maxTurns: 30, summarizeAfter: 20, summarizePrompt: 'Summarize: customer intent, which specialist handled it, actions taken, and pending issues.', summarizeModel: openai('gpt-4o-mini'), }, }); // Conversation 1: Support handles the issue await supervisor.process({ message: 'My API integration is failing', sessionId: 'user_123' }); // Conversation 2: Same session, billing agent sees prior context await supervisor.process({ message: 'Can you check my invoice too?', sessionId: 'user_123' }); ``` ## Observability The supervisor generates traces automatically. With `observability: 'full'`, you get: * **Supervisor span**: which agents were available, which was selected * **LLM call span**: the routing decision (model, tokens, latency) * **Child agent span**: the full execution trace of the selected agent * **Tool call spans**: every tool the child agent invoked ```typescript theme={null} const supervisor = new Agent({ name: 'Traced Router', instructions: 'Route requests.', model: openai('gpt-4o-mini'), agents: { /* ... */ }, observability: 'full', // or 'standard' or 'minimal' }); ``` See [Observability](/core-concepts/observability) for details on trace levels and custom event tracking. ## Fallback Behavior If the LLM returns an agent name that doesn't match any key in the `agents` config, Runflow automatically falls back to the **first agent** in the list. Design your agent order accordingly — put the most general-purpose agent first: ```typescript theme={null} agents: { general: { /* catch-all agent - first in list */ }, sales: { /* specific domain */ }, billing: { /* specific domain */ }, }, ``` ## When to Use Multi-Agent vs Single Agent | Scenario | Recommendation | | ---------------------------------------------------- | --------------------- | | Single-purpose bot (FAQ, scheduling, order status) | Single agent | | Multiple domains with different tools | **Multi-agent** | | Domains that need different models (code vs writing) | **Multi-agent** | | Different response styles per department | **Multi-agent** | | Simple Q\&A with a knowledge base | Single agent with RAG | | Complex routing with domain-specific rules | **Multi-agent** | ## Supervisor vs Workflow The SDK offers two patterns for multi-agent orchestration: | Feature | Supervisor (`agents` config) | Workflow (`flow()`) | | -------------------- | --------------------------------- | ----------------------------------- | | Routing | LLM-based, automatic | Code-based, explicit | | Setup complexity | Minimal (just add `agents`) | More code (steps, switches) | | Control over routing | Instructions-based | Full programmatic control | | Multi-step pipelines | No (single routing hop) | Yes (chain steps, parallel, branch) | | Conditional logic | LLM decides | Explicit conditions | | Best for | Request routing, customer service | Data pipelines, approval flows | Use the supervisor when you need **simple, intent-based routing**. Use [Workflows](/core-concepts/workflows) when you need **multi-step pipelines** with explicit branching, parallel execution, or data transformations. ## Configuration Reference ### Supervisor Agent | Property | Type | Description | | --------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------- | | `name` | `string` | Supervisor identifier | | `instructions` | `string \| PromptRef` | Routing rules and behavior — used as the system prompt for routing decisions | | `model` | `ModelProvider` | LLM for routing (use a cheap, fast model) | | `agents` | `Record` | Child agents keyed by name | | `memory` | `MemoryConfig` | Shared memory across the session | | `observability` | `'full' \| 'standard' \| 'minimal' \| ObservabilityConfig` | Trace level | | `debug` | `boolean \| DebugConfig` | Enable debug logging | ### Child Agent (within `agents`) Each child agent supports the full `AgentConfig`: | Property | Type | Description | | ------------------- | ----------------------------- | ---------------------------------------------------------------- | | `name` | `string` | Agent display name | | `instructions` | `string \| PromptRef` | Specialist behavior and domain expertise | | `model` | `ModelProvider` | LLM for this agent (can differ from supervisor) | | `modelConfig` | `ModelConfig` | Temperature, maxTokens, topP, etc. | | `tools` | `Record` | Agent-specific tools | | `rag` | `RAGConfig` | Knowledge base search | | `memory` | `MemoryConfig` | Agent-specific memory (optional, usually shared from supervisor) | | `media` | `MediaConfig` | Image/audio processing | | `streaming` | `StreamingConfig` | Streaming responses | | `maxToolIterations` | `number` | Max tool call loops | | `debug` | `boolean \| DebugConfig` | Agent-specific debug | ## Next Steps Complete real-world example with supervisor + 3 specialists Create tools for your specialist agents Add knowledge bases to specialists Multi-step pipelines with explicit routing # Tools Source: https://docs.runflow.ai/core-concepts/tools Create type-safe tools for your agents **Tools** are functions that agents can call to perform specific actions. The SDK uses Zod for type-safe validation. ## Create Basic Tool ```typescript theme={null} import { createTool } from '@runflow-ai/sdk'; import { z } from 'zod'; const weatherTool = createTool({ id: 'get-weather', description: 'Get current weather for a location', inputSchema: z.object({ location: z.string().describe('City name'), units: z.enum(['celsius', 'fahrenheit']).optional(), }), outputSchema: z.object({ temperature: z.number(), condition: z.string(), }), execute: async (params) => { // Implement logic const weather = await fetchWeather(params.location); return { temperature: weather.temp, condition: weather.condition, }; }, }); ``` ## Tool with Runflow API ```typescript theme={null} const searchDocsTool = createTool({ id: 'search-docs', description: 'Search in documentation', inputSchema: z.object({ query: z.string(), }), execute: async (params, toolContext) => { // Use Runflow API for vector search const results = await toolContext.runflow.vectorSearch(params.query, { vectorStore: 'docs', k: 5, }); return { results: results.results.map(r => r.content), }; }, }); ``` ## Tool with Connector ```typescript theme={null} const createTicketTool = createTool({ id: 'create-ticket', description: 'Create a support ticket', inputSchema: z.object({ subject: z.string(), description: z.string(), priority: z.enum(['low', 'medium', 'high']), }), execute: async (params, toolContext) => { // Use connector const ticket = await toolContext.runflow.connector( 'hubspot', 'create-ticket', { subject: params.subject, content: params.description, priority: params.priority, } ); return { ticketId: ticket.id }; }, }); ``` ## Tool Execution Context The `execute` function receives two arguments: * `params`: Validated input parameters (parsed from `inputSchema` via Zod) * `toolContext`: An object with `{ projectId, companyId, userId, sessionId, runflow }` for accessing platform APIs ## Using Tools in Agents ```typescript theme={null} const agent = new Agent({ name: 'Weather Agent', instructions: 'You help users check the weather.', model: openai('gpt-4o'), tools: { weather: weatherTool, searchDocs: searchDocsTool, createTicket: createTicketTool, }, }); const result = await agent.process({ message: 'What is the weather in São Paulo?', }); ``` ## Built-in Tools The SDK includes ready-to-use tools that you can add to any agent: ```typescript theme={null} import { createWebSearchTool, createScheduleTools } from '@runflow-ai/sdk'; const agent = new Agent({ name: 'My Agent', model: openai('gpt-4o'), tools: { search: createWebSearchTool({ provider: 'tavily', apiKey: '...' }), ...createScheduleTools(), }, }); ``` * **Web Search** — Search the internet with Tavily, Exa, or Serper * **Schedule** — Create, list, update, and cancel scheduled executions ## Next Steps Add internet search to your agents Create scheduled executions Use HTTP helpers in tools Use built-in connectors # Web Search Source: https://docs.runflow.ai/core-concepts/web-search Give your agents the ability to search the internet for real-time information **Web Search** lets your agents search the internet for up-to-date information. It works as both a **programmatic function** and an **agent tool** that the LLM can invoke automatically. ## Supported Providers | Provider | Strengths | Free Tier | | ---------- | -------------------------------------------- | --------------- | | **Tavily** | AI-native, returns clean content + AI answer | 1,000 req/month | | **Exa** | Semantic/neural search, find similar pages | 1,000 req/month | | **Serper** | Real Google results, very affordable | 2,500 credits | ## Quick Start: Agent with Search The simplest way to give your agent search capabilities: ```typescript theme={null} import { Agent, openai, createWebSearchTool } from '@runflow-ai/sdk'; const agent = new Agent({ name: 'Research Agent', instructions: 'You help users research topics using the internet.', model: openai('gpt-4o'), tools: { search: createWebSearchTool({ provider: 'tavily', apiKey: process.env.TAVILY_API_KEY, }), }, }); const result = await agent.process({ message: 'What are the latest developments in AI agents?', }); ``` The agent will automatically decide when to search the internet based on the user's question. ## Programmatic Search Use `webSearch()` directly in your code, workflows, or custom tools: ```typescript theme={null} import { webSearch } from '@runflow-ai/sdk'; const results = await webSearch('Runflow AI platform', { provider: 'tavily', apiKey: process.env.TAVILY_API_KEY, maxResults: 5, searchDepth: 'advanced', }); console.log(results.answer); // AI-generated answer (Tavily) console.log(results.results); // Array of WebSearchResult ``` ### Response Format ```typescript theme={null} { query: "Runflow AI platform", answer: "Runflow is an AI agent platform...", // Tavily only results: [ { title: "Runflow - Build AI Agents", url: "https://runflow.ai", snippet: "Platform for building and deploying AI agents...", content: "Full page content...", // When includeContent is true score: 0.95, publishedDate: "2026-03-01" } ] } ``` ## Two Modes: Standalone and Platform ### Standalone Mode (API key in code) Pass the API key directly. No platform connection needed: ```typescript theme={null} createWebSearchTool({ provider: 'tavily', apiKey: 'tvly-your-key', }) ``` ### Platform Mode (credential managed in portal) Configure the search provider credential in the Runflow portal (Connectors > Search category), then use without an API key: ```typescript theme={null} createWebSearchTool({ provider: 'tavily', connector: 'tavily-default', // Connector instance slug }) ``` The platform manages the API key securely via the connector credential system. ## Provider Examples ### Tavily (recommended for AI agents) ```typescript theme={null} const results = await webSearch('latest news about AI', { provider: 'tavily', apiKey: process.env.TAVILY_API_KEY, maxResults: 5, searchDepth: 'basic', // 'basic' or 'advanced' includeContent: false, // Include full page content }); ``` ### Exa (semantic search) ```typescript theme={null} const results = await webSearch('articles about building AI agents', { provider: 'exa', apiKey: process.env.EXA_API_KEY, maxResults: 5, }); ``` Exa uses neural search — describe what you're looking for in natural language for best results. ### Serper (Google results) ```typescript theme={null} const results = await webSearch('Runflow AI', { provider: 'serper', apiKey: process.env.SERPER_API_KEY, maxResults: 5, }); ``` ## Configuration Options | Parameter | Type | Default | Description | | ---------------- | ------------------------------- | ---------- | --------------------------------------- | | `provider` | `'tavily' \| 'exa' \| 'serper'` | `'tavily'` | Search provider | | `apiKey` | `string` | — | Provider API key (standalone mode) | | `connector` | `string` | — | Connector instance slug (platform mode) | | `maxResults` | `number` | `5` | Maximum number of results | | `searchDepth` | `'basic' \| 'advanced'` | `'basic'` | Search depth (Tavily only) | | `includeContent` | `boolean` | `false` | Include full page content in results | ## Using in Workflows ```typescript theme={null} import { flow, webSearch } from '@runflow-ai/sdk'; const researchFlow = flow('research') .step('search', async ({ input }) => { const results = await webSearch(input.query, { provider: 'tavily', apiKey: process.env.TAVILY_API_KEY, }); return { results: results.results }; }) .step('summarize', { agent: summaryAgent, prompt: ({ results }) => `Summarize these search results:\n${JSON.stringify(results.search.results)}`, }) .build(); ``` ## Next Steps Create scheduled executions programmatically Create custom tools for agents # Workflows Source: https://docs.runflow.ai/core-concepts/workflows Orchestrate agents, functions, and connectors with a type-safe fluent API **Workflows** let you chain multiple agents, functions, and connectors into a single execution pipeline with branching, parallel execution, iteration, and full observability. ## Quick Start ```typescript theme={null} import { flow, Agent, openai } from '@runflow-ai/sdk'; import { z } from 'zod'; const workflow = flow({ id: 'support-ticket', name: 'Support Ticket Workflow', inputSchema: z.object({ email: z.string().email(), issue: z.string(), }), outputSchema: z.any(), }) .step('classify', async (input) => ({ category: input.issue.toLowerCase().includes('billing') ? 'billing' : 'technical', priority: input.issue.toLowerCase().includes('urgent') ? 'high' : 'normal', })) .step('respond', async (input, ctx) => ({ ticket: `TICKET-${Date.now()}`, category: ctx.results.classify.category, message: `We received your ${input.priority} priority issue.`, })) .build(); const result = await workflow.execute({ email: 'customer@example.com', issue: 'Urgent billing problem', }); ``` The `flow()` API is the recommended way to create workflows. The legacy `createWorkflow()` still works but is deprecated. ## Core Concepts ### Steps Each step receives the **previous step's output** as input, plus a `ctx` object with access to **all previous results**: ```typescript theme={null} .step('enrich', async (input, ctx) => { // `input` = output from previous step // `ctx.results.classify` = output from the 'classify' step // `ctx.results.validate` = output from the 'validate' step // `ctx.input` = original workflow input return { enriched: true, ...input }; }) ``` ### Accessing Previous Step Results Every step receives two arguments: `input` (output from the previous step) and `ctx` (the full workflow context). Use `ctx` to access any previous step's result by name. Use kebab-case IDs for steps (e.g., `fetch-user`, `generate-report`). Avoid spaces, accents, or special characters — the step ID becomes the key in `ctx.results`, so `ctx.results['fetch-user']` is much cleaner than `ctx.results['Buscar Usuário']`. ```typescript theme={null} const workflow = flow({ id: 'pipeline', inputSchema, outputSchema }) .step('fetch-user', async (input) => { const user = await db.getUser(input.userId); return { name: user.name, email: user.email, plan: user.plan }; }) .step('fetch-orders', async (input, ctx) => { // input = output from 'fetch-user' (previous step) // ctx.results['fetch-user'] = same thing, but accessible by name // ctx.input = original workflow input const orders = await db.getOrders(input.email); return { orders, userName: input.name }; }) .step('generate-report', async (input, ctx) => { // Access ANY previous step, not just the immediate one const user = ctx.results['fetch-user']; // { name, email, plan } const orders = ctx.results['fetch-orders']; // { orders, userName } const originalInput = ctx.input; // original workflow input return { report: `${user.name} (${user.plan}): ${orders.orders.length} orders`, requestedBy: originalInput.requestedBy, }; }) .build(); ``` **What's available in `ctx`:** | Property | Type | Description | | ------------------------ | --------------------- | ----------------------------------------------------- | | `ctx.input` | `TInput` | The original workflow input | | `ctx.results` | `Record` | All completed step results, keyed by step ID | | `ctx.results['step-id']` | `any` | Result of a specific step | | `ctx.workflowId` | `string` | Workflow ID | | `ctx.executionId` | `string` | Current execution ID | | `ctx.currentStep` | `string` | Current step ID | | `ctx.metadata` | `object` | Execution metadata (startTime, stepCount, totalSteps) | You can only access results from steps that have **already executed**. Accessing a step that hasn't run yet (or was skipped by a `when` guard) returns `undefined`. **Common patterns:** ```typescript theme={null} // Access original input from any step .step('final', async (input, ctx) => { const email = ctx.input.email; // original workflow input }) // Combine results from multiple previous steps .step('summary', async (input, ctx) => { const classification = ctx.results.classify; const enrichment = ctx.results.enrich; const validation = ctx.results.validate; return { ...classification, ...enrichment, valid: validation.isValid }; }) // Use in conditional guards .step('notify', { handler: async (input, ctx) => { /* ... */ }, when: (ctx) => ctx.results.classify.priority === 'high', }) // Use in branch/switch conditions .switch('route', { on: (ctx) => ctx.results.classify.category, cases: { /* ... */ }, }) ``` ### Schema Validation Steps can declare an `outputSchema` for runtime validation. If the output doesn't match, execution stops immediately with a `ZodError`: ```typescript theme={null} .step('classify', { outputSchema: z.object({ category: z.enum(['billing', 'technical', 'sales']), confidence: z.number().min(0).max(1), }), handler: async (input) => ({ category: 'billing', confidence: 0.95, }), }) ``` ### Conditional Guards Skip steps based on runtime conditions: ```typescript theme={null} .step('notify-manager', { handler: async (input, ctx) => { await notifySlack(ctx.results.classify.category); return { notified: true }; }, when: (ctx) => ctx.results.classify.priority === 'high', }) ``` ## Step Types ### `.step()` -- Function Step Transform data, call APIs, run business logic: ```typescript theme={null} .step('transform', async (input) => ({ normalized: input.text.trim().toLowerCase(), wordCount: input.text.split(' ').length, })) ``` ### `.agent()` -- Agent Step Execute an AI agent. The output is always `{ text, metadata: { agent, model, stepId } }`: ```typescript theme={null} const analyzer = new Agent({ name: 'Analyzer', instructions: 'Analyze customer issues and extract key information.', model: openai('gpt-4o'), }); flow({ id: 'analysis', inputSchema, outputSchema }) .agent('analyze', analyzer, { promptTemplate: 'Analyze this issue: {{input.issue}}', }) .step('extract', async (input) => ({ // input.text = agent's response // input.metadata.agent = 'Analyzer' summary: input.text, })) .build(); ``` ### `.connector()` -- Connector Step (simple) Call an external service with template interpolation: ```typescript theme={null} .connector('create-ticket', 'hubspot', 'tickets', 'create', { subject: 'Support Request', content: '{{input.issue}}', priority: 'medium', }) ``` ### Connectors inside `.step()` (dynamic) For dynamic connector calls with logic, use the `connector()` function inside a `.step()`: ```typescript theme={null} import { flow, connector } from '@runflow-ai/sdk'; flow({ id: 'pipeline', inputSchema, outputSchema }) .step('check-eligibility', async (input) => { const result = await connector( 'api-elegibilidade', // connector instance slug 'consulta-por-cpf', // resource slug { path: { cpf: input.cpf } } // request data ); return { eligible: result.status === 'active', plan: result.plan }; }) .step('create-contact', { handler: async (input, ctx) => { return await connector('hubspot', 'create-contact', { email: ctx.input.email, properties: { plan: input.plan }, }); }, when: (ctx) => ctx.results['check-eligibility'].eligible, }) .build(); ``` `connector()` is a function, not a client factory. Always pass all 3 arguments: `connector(slug, resource, data)`. See [Connectors](/core-concepts/connectors#usage-mode-2-direct-invocation) for details. ## Routing ### `.branch()` -- Binary Routing (if/else) Route to one of two paths based on a condition: ```typescript theme={null} .branch('route', { condition: (ctx) => ctx.results.classify.priority === 'high', onTrue: async (input, ctx) => ({ handler: 'priority-queue', escalated: true, }), onFalse: async (input, ctx) => ({ handler: 'normal-queue', escalated: false, }), }) ``` For complex paths with multiple steps, pass arrays: ```typescript theme={null} .branch('route', { condition: (ctx) => ctx.results.classify.priority === 'high', onTrue: [ createAgentStep('urgent-agent', urgentAgent, { promptTemplate: 'Handle urgent: {{input.issue}}', }), createConnectorStep('notify', 'slack', 'messages', 'send', { channel: '#urgent', message: 'Urgent ticket created', }), ], onFalse: [ createAgentStep('normal-agent', normalAgent), ], }) ``` ### `.switch()` -- Multi-way Routing Route to one of N paths based on a value: ```typescript theme={null} .switch('department', { on: (ctx) => ctx.results.classify.category, cases: { billing: async (input) => ({ agent: 'billing-team', response: '...' }), technical: async (input) => ({ agent: 'tech-team', response: '...' }), sales: async (input) => ({ agent: 'sales-team', response: '...' }), }, default: async (input) => ({ agent: 'general', response: 'Forwarded to support.' }), }) ``` With agent steps per case: ```typescript theme={null} const billingAgent = new Agent({ name: 'Billing', instructions: '...', model: openai('gpt-4o') }); const techAgent = new Agent({ name: 'Tech Support', instructions: '...', model: openai('gpt-4o') }); const salesAgent = new Agent({ name: 'Sales', instructions: '...', model: openai('gpt-4o') }); .switch('department', { on: (ctx) => ctx.results.classify.category, cases: { billing: [createAgentStep('billing', billingAgent, { promptTemplate: '...' })], technical: [createAgentStep('tech', techAgent, { promptTemplate: '...' })], sales: [createAgentStep('sales', salesAgent, { promptTemplate: '...' })], }, default: [createAgentStep('fallback', generalAgent)], }) ``` ## Parallel & Iteration ### `.parallel()` -- Concurrent Execution Run multiple steps at the same time: ```typescript theme={null} .parallel('enrich', [ createFunctionStep('load-profile', async (input) => { return await db.getProfile(input.customerId); }), createFunctionStep('load-orders', async (input) => { return await db.getOrders(input.customerId); }), createFunctionStep('load-tickets', async (input) => { return await db.getTickets(input.customerId); }), ]) // Next step receives: { type: 'parallel', results: { 'load-profile': ..., 'load-orders': ..., 'load-tickets': ... } } ``` ### `.foreach()` -- Array Iteration Process each item in an array, with optional concurrency: ```typescript theme={null} .step('get-leads', async () => { return await db.getUnprocessedLeads(); // returns Lead[] }) .foreach('qualify', { handler: async (lead) => ({ id: lead.id, score: await qualifyLead(lead), qualified: lead.revenue > 10000, }), concurrency: 5, // Process 5 leads at a time }) // Output: QualifiedLead[] ``` ### `.map()` -- Data Transformation Transform output between steps when shapes don't match: ```typescript theme={null} .step('fetch', async () => ({ items: [1, 2, 3, 4, 5], metadata: { total: 5 }, })) .map((output) => output.items) // Extract just the array .foreach('double', { handler: async (item) => item * 2, }) // Output: [2, 4, 6, 8, 10] ``` ## Output Transform Define how to build the final workflow output from all step results: ```typescript theme={null} flow({ id: 'pipeline', inputSchema, outputSchema }) .step('classify', async (input) => ({ category: 'billing' })) .step('process', async (input) => ({ handled: true, response: 'Done' })) .output((results, input) => ({ requestId: `REQ-${Date.now()}`, category: results.classify.category, response: results.process.response, originalInput: input, })) .build(); ``` ## Retry Configuration Add retry logic to any step: ```typescript theme={null} .step('external-call', { handler: async (input) => { const response = await fetch('https://api.example.com/data'); return response.json(); }, retry: { maxAttempts: 3, backoff: 'exponential', // 'fixed' | 'linear' | 'exponential' delay: 1000, // Base delay in ms retryableErrors: ['ETIMEDOUT', 'ECONNREFUSED'], }, }) ``` ## Real-time Events Workflows emit events during execution for monitoring: ```typescript theme={null} const workflow = flow({ id: 'monitored', inputSchema, outputSchema }) .step('a', async (input) => ({ done: true })) .step('b', async (input) => ({ done: true })) .build(); workflow.on('workflow:start', ({ workflowId, executionId }) => { console.log(`Workflow ${workflowId} started: ${executionId}`); }); workflow.on('step:start', ({ stepId, stepType }) => { console.log(`Step ${stepId} (${stepType}) started`); }); workflow.on('step:complete', ({ stepId, durationMs }) => { console.log(`Step ${stepId} completed in ${durationMs}ms`); }); workflow.on('step:skip', ({ stepId, reason }) => { console.log(`Step ${stepId} skipped: ${reason}`); }); workflow.on('workflow:complete', ({ executionId, durationMs }) => { console.log(`Workflow completed in ${durationMs}ms`); }); workflow.on('workflow:error', ({ executionId, error }) => { console.error(`Workflow failed: ${error}`); }); ``` ## Graph Serialization Get the workflow structure as a serializable DAG for visualization: ```typescript theme={null} const graph = workflow.toGraph(); // { // id: 'support-ticket', // name: 'Support Ticket Workflow', // nodes: [ // { id: 'classify', type: 'step', label: 'classify' }, // { id: 'route', type: 'switch', label: 'route' }, // { id: 'route:billing', type: 'step', label: 'billing' }, // { id: 'route:technical', type: 'step', label: 'technical' }, // ], // edges: [ // { source: 'classify', target: 'route' }, // { source: 'route', target: 'route:billing', label: 'billing' }, // { source: 'route', target: 'route:technical', label: 'technical' }, // ], // } ``` ## Full Example: Multi-Agent Customer Service ```typescript theme={null} import { flow, Agent, openai, connector, createAgentStep } from '@runflow-ai/sdk'; import { z } from 'zod'; // Agents const classifier = new Agent({ name: 'Classifier', instructions: `Classify customer issues into categories: billing, technical, sales, general. Return JSON with { category, priority, summary }.`, model: openai('gpt-4o'), }); const billingAgent = new Agent({ name: 'Billing Specialist', instructions: 'Handle billing inquiries. Be precise about amounts and dates.', model: openai('gpt-4o'), }); const techAgent = new Agent({ name: 'Tech Support', instructions: 'Solve technical problems step by step.', model: openai('gpt-4o'), }); // Workflow const customerService = flow({ id: 'customer-service', name: 'Multi-Agent Customer Service', inputSchema: z.object({ customerId: z.string(), message: z.string(), channel: z.enum(['email', 'chat', 'phone']), }), outputSchema: z.any(), }) // 1. Classify the issue .agent('classify', classifier, { promptTemplate: 'Classify this customer message: {{input.message}}', }) // 2. Load customer data in parallel .parallel('load-data', [ createFunctionStep('profile', async (input, ctx) => { return await connector('crm', 'contacts', { action: 'get', id: ctx.input.customerId, }); }), createFunctionStep('history', async (input, ctx) => { return await connector('crm', 'tickets', { action: 'list', contactId: ctx.input.customerId, limit: 5, }); }), ]) // 3. Route to specialist agent .switch('route', { on: (ctx) => { try { return JSON.parse(ctx.results.classify.text).category; } catch { return 'general'; } }, cases: { billing: [createAgentStep('billing-handler', billingAgent, { promptTemplate: 'Customer: {{input.message}}\nHistory: {{results.load-data}}', })], technical: [createAgentStep('tech-handler', techAgent, { promptTemplate: 'Issue: {{input.message}}\nProfile: {{results.load-data}}', })], }, default: async (input, ctx) => ({ text: 'Your request has been forwarded to our support team.', metadata: { agent: 'fallback', model: 'none', stepId: 'route' }, }), }) // 4. Create ticket and send response .step('finalize', async (input, ctx) => { const ticketId = `TICKET-${Date.now()}`; await connector('crm', 'tickets', { action: 'create', contactId: ctx.input.customerId, subject: ctx.results.classify.text, response: input.text, }); return { ticketId, response: input.text, channel: ctx.input.channel, category: ctx.results.classify.text, }; }) // 5. Build final output .output((results, input) => ({ ticketId: results.finalize.ticketId, response: results.finalize.response, channel: input.channel, })) .build(); // Execute const result = await customerService.execute({ customerId: 'cust_123', message: 'I was charged twice for my subscription', channel: 'chat', }); ``` ## Full Example: Lead Qualification Pipeline ```typescript theme={null} const leadPipeline = flow({ id: 'lead-qualification', name: 'Lead Qualification Pipeline', inputSchema: z.object({ leads: z.array(z.object({ id: z.string(), company: z.string(), email: z.string(), revenue: z.number(), })), }), outputSchema: z.any(), }) // Extract leads array .map((input) => input.leads) // Qualify each lead concurrently .foreach('qualify', { handler: async (lead) => { const score = lead.revenue > 100000 ? 'enterprise' : lead.revenue > 10000 ? 'mid-market' : 'smb'; return { ...lead, score, qualified: score !== 'smb' }; }, concurrency: 10, }) // Filter qualified leads .step('filter', async (leads: any[]) => ({ qualified: leads.filter(l => l.qualified), disqualified: leads.filter(l => !l.qualified), total: leads.length, })) // Enrich qualified leads with AI .step('enrich', { handler: async (input, ctx) => ({ ...input, enrichedCount: input.qualified.length, }), when: (ctx) => ctx.results.filter.qualified.length > 0, }) .output((results) => ({ qualified: results.filter.qualified, disqualified: results.filter.disqualified, total: results.filter.total, })) .build(); ``` ## Common Mistakes ### `.parallel()` -- Must receive an array of steps ```typescript theme={null} // WRONG -- passing an object of handlers .parallel('fetch', { tasks: { profile: async () => db.getProfile(), orders: async () => db.getOrders(), }, }) // CORRECT -- pass an array of WorkflowStep .parallel('fetch', [ createFunctionStep('profile', async () => db.getProfile()), createFunctionStep('orders', async () => db.getOrders()), ]) ``` ### `.switch()` -- Use `on`, not `key` or `evaluate` ```typescript theme={null} // WRONG .switch('route', { key: (ctx) => ctx.results.classify.dept, ... }) .switch('route', { evaluate: (ctx) => ctx.results.classify.dept, ... }) // CORRECT .switch('route', { on: (ctx) => ctx.results.classify.dept, cases: { ... } }) ``` ### `.foreach()` -- Input must be an array The previous step must return an array. Use `.map()` to extract it if needed: ```typescript theme={null} // WRONG -- passing an `items` resolver .foreach('process', { items: (ctx) => ctx.results.data.list, // "items" does not exist handler: async (item) => item, }) // CORRECT -- chain .map() before .foreach() .step('fetch', async () => ({ list: [1, 2, 3], total: 3 })) .map((output) => output.list) // extract the array .foreach('process', { handler: async (item) => item * 2, // receives each item }) ``` ### `.map()` -- No ID, no ctx, just a transform function ```typescript theme={null} // WRONG -- passing an ID as first argument .map('transform-name', (input, ctx) => ({ ... })) // CORRECT -- just the function, no ID .map((output) => output.items) .map((output) => ({ ...output, extra: true })) ``` ### `connector()` -- Function call, not a client ```typescript theme={null} // WRONG const client = connector('hubspot'); await client.execute('contacts', data); // CORRECT const result = await connector('hubspot', 'contacts', data); ``` ## Method Reference | Method | Description | Output | | -------------------------- | -------------------------- | -------------------------- | | `.step(id, handler)` | Function step | Handler return type | | `.step(id, opts)` | Function step with options | Handler return type | | `.agent(id, agent, opts?)` | AI agent step | `AgentStepResult` | | `.connector(id, ...)` | External service call | Connector response | | `.branch(id, opts)` | Binary routing (if/else) | `onTrue \| onFalse` return | | `.switch(id, opts)` | Multi-way routing | Matched case return | | `.parallel(id, steps)` | Concurrent execution | `{ results: Record }` | | `.foreach(id, opts)` | Array iteration | `TOut[]` | | `.map(transform)` | Data transformation | Transform return | | `.output(transform)` | Final output builder | -- | | `.build()` | Create Workflow instance | `Workflow` | ## Observability Workflows share the same observability controls as Agents. ### Trace Hierarchy Every workflow execution automatically generates a hierarchical trace tree: ``` workflow_execution > "Order Validation" 12.4s ├── workflow_step > "parse-request" [func] 45ms ├── workflow_step > "validate-cpf" [func] 120ms ├── workflow_step > "check-eligibility" [api] 2.1s │ └── connector_call > "eligibility-api" 2.0s ├── workflow_step > "route-by-type" [switch] 15ms ├── workflow_step > "check-prereqs" [if] 890ms ├── workflow_step > "validate-items" [loop] 3.2s │ ├── workflow_step > "item-1" [func] 1.5s │ └── workflow_step > "item-2" [func] 1.7s ├── workflow_step > "parallel-checks" [parallel] 1.8s │ ├── workflow_step > "fraud-check" [func] 1.2s │ └── connector_call > "network-api" 950ms └── workflow_step > "generate-report" [agent] 4.2s └── agent_execution ├── memory_operation > "load" 65ms ├── llm_call > "chat" 1.3s │ └── rag_search > "knowledge" 1.2s ├── llm_call > "chat" 1.8s └── memory_operation > "save" 70ms ``` ### Controlling Verbosity ```typescript theme={null} // Disable tracing entirely const workflow = flow({ id: 'fast-pipeline', inputSchema, outputSchema, observability: 'minimal', // No traces sent }).step('a', handler).build(); // Truncate large payloads const workflow = flow({ id: 'data-pipeline', inputSchema, outputSchema, observability: { mode: 'standard', maxInputLength: 5000, // Truncate step inputs maxOutputLength: 5000, // Truncate step outputs }, }).step('a', handler).build(); ``` ### Sanitizing Traces Use `onTrace` to remove sensitive data or cancel specific traces: ```typescript theme={null} const workflow = flow({ id: 'medical-workflow', inputSchema, outputSchema, observability: { onTrace: (trace) => { // Remove patient data from traces if (trace.input?.cpf) trace.input.cpf = '***'; if (trace.input?.name) trace.input.name = '***'; // Skip LLM traces (only keep step-level) if (trace.type === 'llm_call') return null; return trace; } }, }).step('a', handler).build(); ``` See [Observability](/core-concepts/observability) for the complete reference on tracing modes, interceptors, and custom logging. ## Next Steps Tracing, interceptors, and metrics Advanced patterns and real-world examples Integrate external services # Dynamic Data Source: https://docs.runflow.ai/dynamic-data Inject dates, user info, and runtime context into your agents LLMs don't know the current date, who your user is, or what plan they're on. You need to inject this information into the agent's context. This page covers the most common patterns for working with dynamic data in production agents. ## The Problem Without dynamic data, your agent is blind to reality: ```typescript theme={null} // The agent has NO idea what day it is // If a user asks "schedule for tomorrow", the agent can't answer correctly const agent = new Agent({ instructions: 'You are a scheduling assistant.', model: openai('gpt-4o'), }); ``` ## Injecting Current Date and Time The most common issue: LLMs don't know today's date. Always inject it into the instructions. ### Using a Function for Instructions Instead of a static string, use a function that builds the instructions dynamically: ```typescript agent.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; import { format } from 'date-fns'; import { ptBR } from 'date-fns/locale'; function buildInstructions() { const now = new Date(); const today = format(now, "EEEE, d 'de' MMMM 'de' yyyy", { locale: ptBR }); const time = format(now, 'HH:mm'); return `You are a scheduling assistant. ## Current Date and Time - Today is: ${today} - Current time: ${time} (Brasília timezone, UTC-3) - Use this as reference for all date calculations ## Behavior - When the user says "tomorrow", calculate from today's date - When the user says "next week", calculate from this week - Always confirm dates explicitly: "Tuesday, March 15th at 2pm" - Never guess dates — always calculate from the current date above ## Tools - Use schedule-appointment to book appointments - Use check-availability to verify open slots`; } export const schedulingAgent = new Agent({ name: 'Scheduling Assistant', instructions: buildInstructions(), model: openai('gpt-4o'), memory: { maxTurns: 20 }, tools: { scheduleAppointment: scheduleAppointmentTool, checkAvailability: checkAvailabilityTool, }, }); ``` If you build the instructions at module load time (outside `main()`), the date will be set when the agent starts and won't update between requests. For most use cases this is fine since deploys are frequent. If you need per-request dates, see the next pattern. ### Per-Request Dynamic Instructions When you need the date to be accurate on every single request, rebuild instructions inside `main()`: ```typescript main.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; import { identify } from '@runflow-ai/sdk/observability'; import { format } from 'date-fns'; import { ptBR } from 'date-fns/locale'; function buildInstructions() { const now = new Date(); return `You are a scheduling assistant. ## Current Date and Time - Today: ${format(now, "EEEE, d 'de' MMMM 'de' yyyy", { locale: ptBR })} - Time: ${format(now, 'HH:mm')} (UTC-3) - Use this for all date calculations`; } export async function main(input: any) { identify(input.email || input.phone || 'anonymous'); // Create agent with fresh date on every request const agent = new Agent({ name: 'Scheduling Assistant', instructions: buildInstructions(), model: openai('gpt-4o'), memory: { maxTurns: 20 }, }); const result = await agent.process({ message: input.message, sessionId: input.sessionId, }); return { message: result.message }; } ``` ## Injecting Context via `messages` The `agent.process()` method accepts a `messages` array alongside `message`. Use it to inject structured context — user profile data, CRM records, previous interactions, or any information the agent needs to respond well. ### Basic Pattern ```typescript theme={null} const result = await agent.process({ message: input.message, sessionId: input.sessionId, messages: [ { role: 'system', content: `## Customer Profile - Name: João Silva - Plan: Enterprise - Account since: 2023-01-15 - Open tickets: 2`, }, ], }); ``` These messages are prepended to the conversation, so the agent sees them as context before the user's message. ### Fetching Context Dynamically in `main.ts` The most common pattern: fetch data from your database or CRM and inject it as context messages. ```typescript main.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; import { identify } from '@runflow-ai/sdk/observability'; const agent = new Agent({ name: 'Support Agent', instructions: `You are a customer support agent. ## Behavior - Use the customer profile provided in context to personalize responses - If the customer has open tickets, ask if they're related - Prioritize Enterprise customers`, model: openai('gpt-4o'), memory: { maxTurns: 20 }, }); export async function main(input: any) { identify(input.email || input.phone || 'anonymous'); // Fetch context from your systems const customer = await fetchCustomer(input.email); const openTickets = await fetchOpenTickets(customer.id); const recentOrders = await fetchRecentOrders(customer.id, { limit: 3 }); const contextMessages = []; // Customer profile contextMessages.push({ role: 'system' as const, content: `## Customer Profile - Name: ${customer.name} - Email: ${customer.email} - Plan: ${customer.plan} - Account since: ${customer.createdAt} - Preferred language: ${customer.language}`, }); // Open tickets if (openTickets.length > 0) { contextMessages.push({ role: 'system' as const, content: `## Open Tickets ${openTickets.map((t: any) => `- ${t.id}: ${t.subject} (${t.status})`).join('\n')}`, }); } // Recent orders if (recentOrders.length > 0) { contextMessages.push({ role: 'system' as const, content: `## Recent Orders ${recentOrders.map((o: any) => `- ${o.id}: ${o.status} — R$ ${o.total} (${o.date})`).join('\n')}`, }); } const result = await agent.process({ message: input.message, sessionId: input.sessionId, messages: contextMessages, }); return { message: result.message }; } ``` ### Combining `messages` with Date Context You can use both `buildInstructions()` for the system prompt and `messages` for per-request context: ```typescript main.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; import { identify } from '@runflow-ai/sdk/observability'; import { format } from 'date-fns'; import { ptBR } from 'date-fns/locale'; const agent = new Agent({ name: 'Sales Agent', instructions: `You are a sales agent for ACME Corp. ## Behavior - Use the lead info provided in context - Reference the current date for follow-up scheduling - Be consultative, not pushy`, model: openai('gpt-4o'), memory: { maxTurns: 30 }, }); export async function main(input: any) { identify(input.email || input.phone || 'anonymous'); const lead = await fetchLead(input.email); const now = new Date(); const result = await agent.process({ message: input.message, sessionId: input.sessionId, messages: [ { role: 'system', content: `## Current Date Today: ${format(now, "EEEE, d 'de' MMMM 'de' yyyy", { locale: ptBR })} Time: ${format(now, 'HH:mm')} (UTC-3)`, }, { role: 'system', content: `## Lead Info - Name: ${lead.name} - Company: ${lead.company} - Role: ${lead.role} - Interest: ${lead.interest} - Last contact: ${lead.lastContactDate || 'First contact'} - Score: ${lead.score}/10`, }, ], }); return { message: result.message }; } ``` ### When to Use `messages` vs `instructions` | Approach | When to Use | | ------------------------------ | ---------------------------------------------------------------- | | `instructions` (static string) | Fixed behavior rules that don't change per request | | `buildInstructions()` function | Rules that depend on dynamic data (date, user plan) | | `messages` array | Per-request context data (CRM records, order history, lead info) | | Both combined | Fixed instructions + dynamic context data | A good rule of thumb: put **behavior rules** in `instructions` and **data** in `messages`. The instructions tell the agent *how* to behave; the messages tell it *what* it's working with. ## Injecting User Context Pass user-specific information into the instructions so the agent knows who it's talking to: ### Basic User Info ```typescript main.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; import { identify } from '@runflow-ai/sdk/observability'; function buildInstructions(user: { name: string; plan: string; language: string }) { return `You are a customer support agent for ACME Corp. ## Customer Context - Name: ${user.name} - Plan: ${user.plan} - Language: ${user.language} ## Behavior - Address the customer by name - Respond in ${user.language} - If they're on the Free plan, mention upgrade options when relevant - If they're on the Enterprise plan, prioritize their requests`; } export async function main(input: any) { identify(input.email || input.phone || 'anonymous'); // Fetch user data from your system const user = await fetchUserFromDB(input.email); const agent = new Agent({ name: 'Support Agent', instructions: buildInstructions({ name: user.name, plan: user.plan, language: user.preferredLanguage || 'pt', }), model: openai('gpt-4o'), memory: { maxTurns: 20 }, }); const result = await agent.process({ message: input.message, sessionId: input.sessionId, }); return { message: result.message }; } ``` ### With Debt/Financial Info (Collections) ```typescript main.ts theme={null} function buildCollectionsPrompt(debt: { customerName: string; amount: number; dueDate: string; daysOverdue: number; }) { return `You are a professional debt collection agent. ## Customer & Debt Info - Customer: ${debt.customerName} - Amount due: R$ ${debt.amount.toFixed(2)} - Original due date: ${debt.dueDate} - Days overdue: ${debt.daysOverdue} ## Strategy ${debt.daysOverdue <= 7 ? '- Be gentle — this is a recent overdue. A friendly reminder is enough.' : debt.daysOverdue <= 30 ? '- Be firm but empathetic. Offer a payment plan if needed.' : '- This is significantly overdue. Offer flexible payment options. Escalate if refused.'} ## Rules - Never be aggressive or threatening - You may share the amount with the customer directly - Never share the amount with third parties`; } ``` ## Using `loadPrompt()` with Variables For prompts managed in the Runflow portal, use `loadPrompt()` with template variables: ```typescript agent.ts theme={null} import { Agent, openai, loadPrompt } from '@runflow-ai/sdk'; import { format } from 'date-fns'; const agent = new Agent({ name: 'Support Agent', instructions: loadPrompt('customer-support', { currentDate: format(new Date(), 'yyyy-MM-dd'), product: 'CRM Pro', language: 'Portuguese', }), model: openai('gpt-4o'), }); ``` In the portal, your prompt template would look like: ``` You are a support agent for {{product}}. Today's date is {{currentDate}}. Respond in {{language}}. ``` Use `loadPrompt()` when you want non-developers (product managers, prompt engineers) to edit prompts through the portal without code changes. Use local functions when the prompt logic is complex or involves conditionals. ## Date Handling for Scheduling Scheduling is one of the hardest tasks for LLMs. Here are patterns that work in production. ### Always Provide Today's Date The single most important thing: always tell the LLM what today's date is. ```typescript theme={null} instructions: `... ## Current Date Today is Wednesday, March 12, 2025. Current time: 14:30 (UTC-3). When the user says: - "tomorrow" → Thursday, March 13, 2025 - "next Monday" → Monday, March 17, 2025 - "in 2 weeks" → Wednesday, March 26, 2025 Always confirm the calculated date with the user before scheduling.` ``` ### Scheduling Tool with Date Validation Don't trust the LLM to calculate dates correctly. Validate in the tool: ```typescript tools/schedule-appointment.ts theme={null} import { createTool } from '@runflow-ai/sdk'; import { track } from '@runflow-ai/sdk/observability'; import { z } from 'zod'; import { parseISO, isBefore, isWeekend, format, startOfDay } from 'date-fns'; export const scheduleAppointmentTool = createTool({ id: 'schedule-appointment', description: 'Schedule an appointment on a specific date and time', inputSchema: z.object({ date: z.string().describe('Appointment date in ISO format (YYYY-MM-DD)'), time: z.string().describe('Appointment time (HH:mm)'), description: z.string().describe('What the appointment is about'), }), execute: async (params) => { const appointmentDate = parseISO(`${params.date}T${params.time}:00`); const now = new Date(); // Validate: not in the past if (isBefore(appointmentDate, now)) { return { success: false, error: `Cannot schedule in the past. The requested date ${params.date} ${params.time} has already passed. Current date is ${format(now, 'yyyy-MM-dd HH:mm')}.`, }; } // Validate: not on weekends if (isWeekend(appointmentDate)) { return { success: false, error: `${format(appointmentDate, 'EEEE, MMMM d')} is a weekend. Please choose a weekday.`, }; } // Validate: business hours (9-18) const hour = parseInt(params.time.split(':')[0]); if (hour < 9 || hour >= 18) { return { success: false, error: 'Appointments are only available between 9:00 and 18:00.', }; } // Schedule the appointment const appointment = await createAppointment({ date: appointmentDate, description: params.description, }); track('appointment_scheduled', { date: params.date, dayOfWeek: format(appointmentDate, 'EEEE'), }); return { success: true, id: appointment.id, date: format(appointmentDate, "EEEE, MMMM d 'at' HH:mm"), confirmation: `Appointment confirmed for ${format(appointmentDate, "EEEE, MMMM d 'at' HH:mm")}`, }; }, }); ``` ### Availability Check Tool Let the agent check available slots instead of guessing: ```typescript tools/check-availability.ts theme={null} import { createTool } from '@runflow-ai/sdk'; import { z } from 'zod'; import { parseISO, format, addDays, isWeekend } from 'date-fns'; export const checkAvailabilityTool = createTool({ id: 'check-availability', description: 'Check available appointment slots for a date or date range', inputSchema: z.object({ date: z.string().describe('Start date in ISO format (YYYY-MM-DD)'), days: z.number().optional().describe('Number of days to check (default: 1, max: 7)'), }), execute: async (params) => { const startDate = parseISO(params.date); const daysToCheck = Math.min(params.days || 1, 7); const slots: { date: string; times: string[] }[] = []; for (let i = 0; i < daysToCheck; i++) { const day = addDays(startDate, i); if (isWeekend(day)) continue; // Fetch from your calendar/booking system const available = await getAvailableSlots(day); slots.push({ date: format(day, 'yyyy-MM-dd (EEEE)'), times: available.map((s: any) => s.time), }); } if (!slots.length) { return { available: false, message: 'No available slots in the requested period' }; } return { available: true, slots }; }, }); ``` ## Combining Everything: Scheduling Agent A complete example that combines date injection, user context, and scheduling tools: ```typescript main.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; import { identify, track } from '@runflow-ai/sdk/observability'; import { format } from 'date-fns'; import { ptBR } from 'date-fns/locale'; import { scheduleAppointmentTool } from './tools/schedule-appointment'; import { checkAvailabilityTool } from './tools/check-availability'; function buildInstructions(userName: string) { const now = new Date(); const today = format(now, "EEEE, d 'de' MMMM 'de' yyyy", { locale: ptBR }); const time = format(now, 'HH:mm'); return `You are a scheduling assistant for ACME Clinic. ## Current Date and Time - Today: ${today} - Time: ${time} (Brasília, UTC-3) ## Customer - Name: ${userName} ## Behavior - Address the customer by name - Always check availability before scheduling - Confirm the full date and time with the customer before booking - Business hours: Monday to Friday, 9:00 to 18:00 - Respond in Portuguese ## Tools - Use check-availability FIRST to see open slots - Use schedule-appointment to book after customer confirms - Never schedule without checking availability first`; } export async function main(input: any) { if (!input?.message) { return { error: 'message is required' }; } const phone = input.phone || input.from; identify(phone || input.email || 'anonymous'); // Fetch user info const user = await fetchUser(phone); const agent = new Agent({ name: 'Scheduling Assistant', instructions: buildInstructions(user?.name || 'Cliente'), model: openai('gpt-4o'), memory: { maxTurns: 20 }, modelConfig: { temperature: 0 }, tools: { checkAvailability: checkAvailabilityTool, scheduleAppointment: scheduleAppointmentTool, }, observability: 'full', }); try { const result = await agent.process({ message: input.message, sessionId: input.sessionId || `scheduling_${phone}`, }); track('scheduling_interaction', { hasAppointment: result.metadata?.toolsUsed?.includes('schedule-appointment'), }); return { message: result.message }; } catch (error) { console.error('[scheduling] Error:', error); return { error: 'An error occurred. Please try again.' }; } } ``` ## Summary | Pattern | When to Use | | ------------------------------ | -------------------------------------------------------------- | | Inject date in instructions | Always — LLMs don't know today's date | | `buildInstructions()` function | Dynamic data with code logic (conditionals, formatting) | | `loadPrompt()` with variables | Prompts managed in the portal by non-developers | | Per-request agent creation | When data must be fresh on every request (dates, user context) | | Date validation in tools | Always — never trust the LLM to calculate dates correctly | Never trust the LLM to calculate dates. Always validate dates in your tools — check for past dates, weekends, business hours, and conflicts. The LLM should propose, your tool should validate. ## Next Steps Manage prompts with loadPrompt() Build validation tools Tips for effective agents User identification patterns # Welcome to Runflow Source: https://docs.runflow.ai/index Build intelligent AI agents and multi-agent systems with TypeScript ## What is Runflow? Runflow is a complete platform for building, deploying, and managing AI agents. It combines a powerful **TypeScript SDK** with a **command-line interface (CLI)** and a **management portal**, making it easy to create production-ready AI applications. Start in minutes with the command line Create your first agent with code Learn about Agents, Memory, Tools, and more See real-world use cases ## Why Runflow? Use the CLI to authenticate, clone an agent, and start testing in under 2 minutes. No complex setup required. Built with TypeScript-first design. Full type safety with Zod validation ensures your agents work correctly. Built-in observability, memory management, RAG, and connectors. Everything you need for production deployments. Create complex systems with multiple specialized agents using the supervisor pattern. ## Your Journey with Runflow ### 1. Get Started with CLI (2 minutes) The fastest way to start is with the CLI: ```bash theme={null} # Install npm i -g @runflow-ai/cli # Login rf login # List and clone an agent rf agents list # Test locally cd agent-name/ rf test ``` Learn how to use the CLI ### 2. Build with Code Once you understand the basics, dive into the SDK: ```typescript theme={null} import { Agent, openai } from "@runflow-ai/sdk"; // Create a basic agent const agent = new Agent({ name: "Support Agent", instructions: "You are a helpful customer support assistant.", model: openai("gpt-4o"), memory: { maxTurns: 10 }, }); // Process a message const result = await agent.process({ message: "I need help with my order", sessionId: "session_456", }); ``` Create your first agent with code ### 3. Learn Core Concepts Understand the powerful features: * **Agents** - Intelligent AI assistants with LLM, tools, and memory * **Memory** - Persistent conversation history across sessions * **Tools** - Custom functions your agents can call * **Connectors** - Integrate with HubSpot, Twilio, Slack, and more * **Workflows** - Orchestrate complex multi-step processes * **RAG** - Semantic search in your knowledge base * **Observability** - Full tracing with cost tracking Learn about Agents, Memory, Tools, and more ### 4. See Real-World Examples Explore production-ready examples: * Customer Support Agent with RAG * Sales Automation Workflows * Collections Agent (WhatsApp) * Customer Onboarding Assistant * Feedback Analysis System * Multi-Agent Systems See practical implementations ## Features Create agents with LLM, tools, memory, and RAG capabilities Build custom tools with Zod schema validation Connect to any API with runtime schema loading Orchestrate complex multi-step processes Persistent conversation history with auto-summarization LLM-driven semantic search in knowledge bases Supervisor pattern with automatic routing Automatic tracing with cost tracking and metrics ## Built-in Libraries The SDK includes these libraries out-of-the-box. **No need to install separately**: | Library | Description | | ------------ | ------------------------------------------ | | **axios** | HTTP client for API requests | | **zod** | Schema validation and TypeScript inference | | **date-fns** | Modern date utility library | | **lodash** | JavaScript utility library | | **cheerio** | HTML/XML parsing | | **pino** | Fast JSON logger | ## Community & Support View source code and contribute Join our community Get help from our team Visit Runflow\.ai *** **Built with ❤️ by the Runflow team** # Installation Source: https://docs.runflow.ai/installation Install and configure Runflow SDK ## Installation Install the SDK using your preferred package manager: ```bash theme={null} npm install @runflow-ai/sdk # or yarn add @runflow-ai/sdk # or pnpm add @runflow-ai/sdk ``` ## Requirements * **Node.js**: >= 22.0.0 * **TypeScript**: >= 5.0.0 (recommended) ## Project Entry Point Every Runflow project needs a `main.ts` file at the root that exports an `async function main()`. This is the function the Runflow engine calls when your agent receives a message: ```typescript main.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; import { identify } from '@runflow-ai/sdk/observability'; const agent = new Agent({ name: 'My Agent', instructions: 'You are a helpful assistant.', model: openai('gpt-4o'), }); export async function main(input: any) { identify(input.email || input.phone || 'anonymous'); const result = await agent.process({ message: input.message, sessionId: input.sessionId, }); return { message: result.message }; } ``` Learn how to organize your project as it grows ## Built-in Libraries The SDK includes the following libraries out-of-the-box. **No need to install them separately** - they're available in all your agents: | Library | Version | Description | Import | | ------------ | -------- | ------------------------------------------ | -------------------------------------------- | | **axios** | ^1.7.0 | HTTP client for API requests | `import axios from 'axios'` | | **zod** | ^3.22.0 | Schema validation and TypeScript inference | `import { z } from 'zod'` | | **date-fns** | ^3.0.0 | Modern date utility library | `import { format, addDays } from 'date-fns'` | | **lodash** | ^4.17.21 | JavaScript utility library | `import _ from 'lodash'` | | **cheerio** | ^1.0.0 | Fast, flexible HTML/XML parsing | `import * as cheerio from 'cheerio'` | | **pino** | ^8.19.0 | Fast JSON logger | `import pino from 'pino'` | ### Quick Examples ```typescript theme={null} import { createTool } from '@runflow-ai/sdk'; import { z } from 'zod'; import axios from 'axios'; import { format, addDays } from 'date-fns'; import _ from 'lodash'; const myTool = createTool({ id: 'example-tool', description: 'Shows all available libraries', inputSchema: z.object({ url: z.string().url(), data: z.array(z.any()), }), execute: async (params) => { // ✅ HTTP requests with axios const response = await axios.get(params.url); // ✅ Date manipulation const tomorrow = addDays(new Date(), 1); const formatted = format(tomorrow, 'yyyy-MM-dd'); // ✅ Array/Object utilities with lodash const unique = _.uniq(params.data); const grouped = _.groupBy(params.data, 'category'); return { response: response.data, date: formatted, unique, grouped }; }, }); ``` You can also use the SDK's HTTP helpers for convenience: ```typescript theme={null} import { httpGet, httpPost } from '@runflow-ai/sdk/http'; const data = await httpGet('https://api.example.com/data'); ``` ## Environment Variables Set up your environment variables: ```bash theme={null} # API Configuration RUNFLOW_API_URL=http://localhost:3001 RUNFLOW_API_KEY=your_api_key_here RUNFLOW_TENANT_ID=your_tenant_id RUNFLOW_AGENT_ID=your_agent_id # Execution Context (optional - usually comes from engine) RUNFLOW_EXECUTION_ID=exec_123 RUNFLOW_THREAD_ID=thread_456 # Development RUNFLOW_ENV=development RUNFLOW_LOCAL_TRACES=true NODE_ENV=development ``` ## Configuration File Create a `.runflow/rf.json` file: ```json theme={null} { "agentId": "your_agent_id", "tenantId": "your_tenant_id", "apiKey": "your_api_key", "apiUrl": "http://localhost:3001" } ``` The SDK automatically searches for `.runflow/rf.json` in the current directory and parent directories. To make these values available as environment variables for external tools (Promptfoo, test runners, custom scripts), add: ```typescript theme={null} import '@runflow-ai/sdk/init'; ``` See [Configuration File](/configuration/config-file) for details. **Configuration Priority:** 1. Explicit config in code 2. `.runflow/rf.json` 3. Environment variables 4. Defaults ## Manual API Client Configuration ```typescript theme={null} import { createRunflowAPIClient, Agent, openai } from '@runflow-ai/sdk'; const apiClient = createRunflowAPIClient({ apiUrl: 'https://api.runflow.ai', apiKey: 'your_api_key', tenantId: 'your_tenant_id', agentId: 'your_agent_id', }); // Inject into agent const agent = new Agent({ name: 'My Agent', instructions: 'Help users', model: openai('gpt-4o'), }); agent._setAPIClient(apiClient); ``` ## Next Steps Create your first agent Learn about Agents # Project Structure Source: https://docs.runflow.ai/project-structure How to organize your Runflow agent project Every Runflow project follows a simple convention: a `main.ts` file at the root that exports an `async function main()`. As your project grows, you organize code into folders that map directly to SDK concepts. ## Entry Point: `main.ts` The `main.ts` file is the only required file. It must export an `async function main(input)` — this is the contract between your code and the Runflow engine. ```typescript main.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; import { identify } from '@runflow-ai/sdk/observability'; const agent = new Agent({ name: 'My Agent', instructions: '...', model: openai('gpt-4o'), }); export async function main(input: any) { identify(input.email || input.phone); const result = await agent.process({ message: input.message, sessionId: input.sessionId, }); return { message: result.message }; } ``` **What `main()` receives:** * `input.message` — the user's message (always present) * `input.sessionId` — session identifier (for memory continuity) * `input.email`, `input.phone` — user identifiers (depends on your integration) * Any other fields your integration sends **What `main()` returns:** * An object with at least `message` — the agent's response * Any additional metadata you want to pass back ## Project Sizes ### Simple Project For a basic agent with one or two tools, keep everything minimal: ``` my-agent/ ├── main.ts # Agent + main function ├── tools/ │ └── weather.ts # One file per tool ├── .runflow/ │ └── rf.json ├── package.json └── tsconfig.json ``` ### Medium Project When you have multiple tools and want better organization: ``` my-agent/ ├── main.ts # Entry point + orchestration ├── agent.ts # Agent definition (separated from main) ├── tools/ │ ├── index.ts # Re-exports all tools │ ├── create-ticket.ts │ ├── search-orders.ts │ └── send-email.ts ├── prompts/ │ └── index.ts # System prompt + templates ├── .runflow/ │ └── rf.json ├── package.json └── tsconfig.json ``` ### Complex Project For enterprise agents with workflows, connectors, and integrations: ``` my-agent/ ├── main.ts # Entry point + routing logic ├── agent.ts # Agent definition ├── tools/ │ ├── index.ts │ ├── create-ticket.ts │ ├── classify-intent.ts │ └── send-notification.ts ├── prompts/ │ └── index.ts # System prompt + scenario prompts ├── workflows/ │ └── lead-qualification.ts ├── connectors/ │ └── hubspot.ts # Connector configurations ├── config/ │ └── settings.ts # Constants, enums, configurations ├── .runflow/ │ └── rf.json ├── package.json └── tsconfig.json ``` ## Folder Guide ### `tools/` One file per tool. Each file exports a single tool created with `createTool()`. ```typescript tools/create-ticket.ts theme={null} import { createTool } from '@runflow-ai/sdk'; import { z } from 'zod'; export const createTicketTool = createTool({ id: 'create-ticket', description: 'Create a support ticket in the system', inputSchema: z.object({ subject: z.string(), description: z.string(), priority: z.enum(['low', 'medium', 'high']), }), execute: async (params) => { // Your logic here return { ticketId: 'TICKET-123', success: true }; }, }); ``` Use an `index.ts` to re-export all tools: ```typescript tools/index.ts theme={null} export { createTicketTool } from './create-ticket'; export { searchOrdersTool } from './search-orders'; export { sendEmailTool } from './send-email'; ``` Then import them cleanly in your agent: ```typescript agent.ts theme={null} import { createTicketTool, searchOrdersTool, sendEmailTool } from './tools'; const agent = new Agent({ tools: { createTicket: createTicketTool, searchOrders: searchOrdersTool, sendEmail: sendEmailTool, }, }); ``` ### `prompts/` Centralize your prompts in a dedicated file, especially when they're long or have multiple scenarios: ```typescript prompts/index.ts theme={null} export const systemPrompt = `You are a customer support agent for ACME Corp. ## Behavior - Always be professional and empathetic - Respond in the customer's language - Search the knowledge base before answering technical questions ## Tools - Use create-ticket for issues that need human follow-up - Use search-orders when customers ask about their orders - Use send-email to notify the team about urgent issues ## Response Format - Be concise but complete - Use bullet points for lists - Always confirm actions taken`; export const escalationPrompt = `A customer issue needs escalation. Customer: {{customerName}} Issue: {{issue}} Priority: {{priority}} Write a brief summary for the support team.`; ``` For prompts managed through the Runflow portal, use `loadPrompt()` instead of local files. See [Prompts](/core-concepts/prompts) for details. ### `workflows/` Store workflow definitions in dedicated files: ```typescript workflows/lead-qualification.ts theme={null} import { createWorkflow } from '@runflow-ai/sdk'; import { z } from 'zod'; export const leadQualificationWorkflow = createWorkflow({ id: 'lead-qualification', inputSchema: z.object({ email: z.string().email(), company: z.string(), notes: z.string(), }), // ... workflow steps }); ``` ### `connectors/` When using multiple connectors, configure them in dedicated files: ```typescript connectors/hubspot.ts theme={null} import { createConnectorTool } from '@runflow-ai/sdk'; export const createContactTool = createConnectorTool('hubspot', 'create_contact'); export const updateDealTool = createConnectorTool('hubspot', 'update_deal'); ``` ### `config/` For projects with many constants, enums, or configuration values: ```typescript config/settings.ts theme={null} export const PRIORITIES = ['low', 'medium', 'high', 'urgent'] as const; export type Priority = (typeof PRIORITIES)[number]; export const AGENT_CONFIG = { model: 'gpt-4o', temperature: 0, maxTokens: 3000, memoryMaxTurns: 50, }; ``` ## Separating the Agent from `main.ts` As your project grows, move the agent definition to its own file. This keeps `main.ts` focused on orchestration: ```typescript agent.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; import { systemPrompt } from './prompts'; import { createTicketTool, searchOrdersTool } from './tools'; export const supportAgent = new Agent({ name: 'Support Agent', instructions: systemPrompt, model: openai('gpt-4o'), memory: { maxTurns: 20 }, tools: { createTicket: createTicketTool, searchOrders: searchOrdersTool, }, observability: 'full', }); ``` ```typescript main.ts theme={null} import { identify, track } from '@runflow-ai/sdk/observability'; import { supportAgent } from './agent'; export async function main(input: any) { identify(input.email || input.phone); const result = await supportAgent.process({ message: input.message, sessionId: input.sessionId, }); track('support_request', { channel: input.channel, resolved: !result.metadata?.toolsUsed?.includes('createTicket'), }); return { message: result.message }; } ``` ## Next Steps Tips for writing effective agents Learn how to create tools Build multi-step workflows Manage prompt templates # Built-in Providers Source: https://docs.runflow.ai/providers/built-in-providers Use Runflow's built-in LLM, memory, and knowledge providers ## Built-in LLM Providers Runflow comes with 8 built-in LLM provider integrations. Configure them in the portal and use in code: ```typescript theme={null} import { openai, anthropic, bedrock, groq, gemini, xai, custom } from '@runflow-ai/sdk'; model: openai('gpt-4o') // OpenAI model: anthropic('claude-sonnet-4-6') // Anthropic model: bedrock('anthropic.claude-3-5-sonnet-20241022-v2:0') // AWS Bedrock model: groq('llama-3.3-70b-versatile') // Groq model: gemini('gemini-2.5-flash') // Google Gemini model: xai('grok-4-1-fast-non-reasoning') // xAI (Grok) model: custom('llama3', 'Ollama Local') // Any OpenAI-compatible API ``` Azure OpenAI is also supported — use `openai()` with a `providerName` pointing to your Azure configuration. See [LLM Providers](/providers/llm-provider) for full documentation on each provider, credentials, and named configurations. ## Built-in Memory Provider Use Runflow's managed memory backend: ```typescript theme={null} import { RunflowMemoryProvider } from '@runflow-ai/sdk'; const memory = new Memory({ provider: new RunflowMemoryProvider(apiClient), maxTurns: 10, }); ``` ## Next Steps Configure and use LLM providers Learn about memory providers Learn about knowledge providers # Knowledge Provider Interface Source: https://docs.runflow.ai/providers/knowledge-provider Implement custom knowledge providers ## Knowledge Provider Interface ```typescript theme={null} interface KnowledgeProvider { search(query: string, options: SearchOptions): Promise; embed?(text: string): Promise; } ``` ## Next Steps Learn about LLM providers See built-in providers # LLM Providers Source: https://docs.runflow.ai/providers/llm-provider Configure and use multiple LLM providers — OpenAI, Anthropic, Bedrock, Groq, Gemini, xAI, Azure OpenAI, and custom providers Runflow supports **8 LLM provider types** out of the box. You configure providers and credentials in the portal, then reference them in your agent code with simple helper functions. ## How It Works Go to **Settings > LLM Providers** and add a provider with its credentials (API key, AWS credentials, etc). Runflow automatically discovers available models for your provider and shows them in the model picker. Import the provider helper and pass the model name — Runflow handles credential resolution, API routing, and response normalization. ## Provider Helpers The SDK exports a helper function for each provider type: ```typescript theme={null} import { openai, anthropic, bedrock, groq, gemini, xai, custom } from '@runflow-ai/sdk'; ``` Each helper returns a `ModelProvider` object that tells the runtime which provider and model to use: ```typescript theme={null} interface ModelProvider { provider: 'openai' | 'anthropic' | 'bedrock' | 'groq' | 'gemini' | 'xai' | 'custom'; model: string; providerName?: string; // Target a specific provider configuration by name legacy?: boolean; // Use legacy Chat Completions API (OpenAI only) } ``` ## Supported Providers ### OpenAI ```typescript theme={null} import { openai } from '@runflow-ai/sdk'; const agent = new Agent({ name: 'Assistant', instructions: 'You are a helpful assistant.', model: openai('gpt-4o'), }); ``` **Credential**: API Key (`sk-...`) **Popular models**: `gpt-4o`, `gpt-4o-mini`, `gpt-4.1`, `gpt-4.1-mini`, `gpt-5.4`, `o3`, `o4-mini`, `o1` *** ### Anthropic (Claude) ```typescript theme={null} import { anthropic } from '@runflow-ai/sdk'; const agent = new Agent({ name: 'Assistant', instructions: 'You are a helpful assistant.', model: anthropic('claude-sonnet-4-20250514'), }); ``` **Credential**: API Key (`sk-ant-...`) **Popular models**: `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-20250514`, `claude-haiku-4-5-20251001`, `claude-3-5-sonnet-20241022` *** ### AWS Bedrock Use Claude, Titan, Llama, and other models through your AWS account — no separate API keys needed, billing goes through AWS. ```typescript theme={null} import { bedrock } from '@runflow-ai/sdk'; const agent = new Agent({ name: 'Assistant', instructions: 'You are a helpful assistant.', model: bedrock('anthropic.claude-3-5-sonnet-20241022-v2:0'), }); ``` **Credential**: AWS Access Key + Secret Key (stored as encrypted secret with `accessKeyId`, `secretAccessKey`, and optionally `region`) **Popular models**: `anthropic.claude-3-5-sonnet-20241022-v2:0`, `anthropic.claude-3-haiku-20240307-v1:0`, `amazon.titan-text-express-v1`, `meta.llama3-70b-instruct-v1:0` *** ### Groq Ultra-fast inference for open-source models. ```typescript theme={null} import { groq } from '@runflow-ai/sdk'; const agent = new Agent({ name: 'Fast Assistant', instructions: 'You are a helpful assistant.', model: groq('llama-3.3-70b-versatile'), }); ``` **Credential**: API Key (`gsk_...`) **Popular models**: `llama-3.3-70b-versatile`, `llama-3.1-8b-instant`, `mixtral-8x7b-32768`, `gemma2-9b-it` *** ### Google Gemini ```typescript theme={null} import { gemini } from '@runflow-ai/sdk'; const agent = new Agent({ name: 'Assistant', instructions: 'You are a helpful assistant.', model: gemini('gemini-2.5-flash'), }); ``` **Credential**: API Key (`AIza...`) **Popular models**: `gemini-2.5-flash`, `gemini-2.5-pro`, `gemini-3-flash-preview`, `gemini-3-pro-preview`, `gemini-2.0-flash` *** ### xAI (Grok) High-performance reasoning models with native web search and X/Twitter search. ```typescript theme={null} import { xai } from '@runflow-ai/sdk'; const agent = new Agent({ name: 'Research Agent', instructions: 'You are a research assistant.', model: xai('grok-4-1-fast-non-reasoning'), }); ``` **Credential**: API Key (`xai-...`) **Popular models**: `grok-4-1-fast-non-reasoning`, `grok-4-1-fast-reasoning`, `grok-4.20-0309-reasoning`, `grok-3`, `grok-3-mini` **Reasoning models**: Models with `-reasoning` in the name use chain-of-thought reasoning (similar to OpenAI o-series). *** ### Azure OpenAI Use OpenAI models hosted on your Azure subscription. Configure this provider in the portal with your Azure endpoint and deployment name. In code, use `openai()` with `providerName` pointing to your Azure configuration: ```typescript theme={null} import { openai } from '@runflow-ai/sdk'; const agent = new Agent({ name: 'Assistant', instructions: 'You are a helpful assistant.', model: openai('gpt-4o', { providerName: 'Azure Production' }), }); ``` **Credential**: API Key or Secret (with `endpoint` and `deploymentName`) *** ### Custom (OpenAI-Compatible) Connect any OpenAI-compatible API — Ollama, LiteLLM, vLLM, LM Studio, or any other provider that follows the OpenAI API format. ```typescript theme={null} import { custom } from '@runflow-ai/sdk'; // providerName is required — matches the name configured in the portal const agent = new Agent({ name: 'Local Assistant', instructions: 'You are a helpful assistant.', model: custom('llama3', 'Ollama Local'), }); ``` **Credential**: Varies (API Key, Bearer Token, Basic Auth, or Secret with `baseUrl`) **Use cases**: Self-hosted models, private deployments, specialized inference endpoints ## Named Provider Configurations If you have multiple configurations of the same provider type (e.g., separate OpenAI keys for dev and production), use `providerName` to target a specific one: ```typescript theme={null} // Uses the default OpenAI provider model: openai('gpt-4o') // Uses a specific named configuration model: openai('gpt-4o', { providerName: 'OpenAI Production' }) model: anthropic('claude-sonnet-4-20250514', { providerName: 'Anthropic Dev' }) model: bedrock('anthropic.claude-3-5-sonnet-20241022-v2:0', { providerName: 'AWS US-East' }) ``` This is useful when you need: * **Environment isolation**: Different API keys for dev/staging/production * **Cost control**: Route expensive calls through a specific key with budget limits * **Regional routing**: Target specific AWS regions for Bedrock ## Using with LLM Standalone All providers work with direct LLM calls (no agent needed): ```typescript theme={null} import { LLM } from '@runflow-ai/sdk'; const classifier = LLM.openai('gpt-4o-mini', { temperature: 0 }); const writer = LLM.anthropic('claude-sonnet-4-20250514', { temperature: 0.7 }); const fast = LLM.groq('llama-3.3-70b-versatile', { temperature: 0.3 }); const flash = LLM.gemini('gemini-2.5-flash'); const research = LLM.xai('grok-4-1-fast-reasoning'); const local = LLM.custom('llama3', 'Ollama Local'); const result = await classifier.generate('Classify this text...'); ``` See [LLM Standalone](/core-concepts/llm-standalone) for more examples. ## Model Discovery When you add a provider in the portal, Runflow can **auto-discover** available models by querying the provider's API. Discovered models include metadata like: * Maximum context window size * Streaming support * Tool/function calling support * Vision/multimodal support * Cost per 1K tokens (input/output) You can also manually add models or trigger a re-sync at any time. ## Next Steps Create agents with any provider Direct LLM calls without agents Build your own memory backend Real-time streaming responses # Memory Provider Interface Source: https://docs.runflow.ai/providers/memory-provider Implement custom memory providers ## Memory Provider Interface ```typescript theme={null} interface MemoryProvider { get(key: string): Promise; set(key: string, data: MemoryData): Promise; append(key: string, message: MemoryMessage): Promise; clear(key: string): Promise; summarize?(key: string): Promise; search?(key: string, query: string): Promise; } ``` ## Next Steps Learn about knowledge providers See built-in providers # Quick Start Source: https://docs.runflow.ai/quickstart Build your first Runflow agent in 5 minutes ## 1. Create Your Project The fastest way to start is with the CLI: ```bash npm theme={null} npm i -g @runflow-ai/cli rf login # opens your browser to sign in rf create --name my-agent --template starter --yes cd my-agent/ ``` ```bash yarn theme={null} npm i -g @runflow-ai/cli rf login # opens your browser to sign in rf create --name my-agent --template starter --yes cd my-agent/ ``` This creates the following structure: ``` my-agent/ ├── main.ts # Entry point (required) ├── tools/ │ └── weather.ts # Example tool ├── .runflow/ │ └── rf.json # Project configuration ├── package.json └── tsconfig.json ``` ## 2. Understanding `main.ts` Every Runflow agent needs a `main.ts` file at the project root. It must export an `async function main()` — this is the function the Runflow engine calls when your agent receives a message. ```typescript theme={null} import { Agent, openai } from '@runflow-ai/sdk'; import { identify } from '@runflow-ai/sdk/observability'; // Create your agent const agent = new Agent({ name: 'My First Agent', instructions: 'You are a helpful assistant. Be concise and friendly.', model: openai('gpt-4o'), }); // Entry point — called by Runflow engine export async function main(input: any) { // 1. Identify the user (connects memory, traces, and metrics to this person) identify(input.email || input.phone || 'anonymous'); // 2. Process the message const result = await agent.process({ message: input.message, sessionId: input.sessionId, }); // 3. Return the response return { message: result.message, }; } ``` The `main.ts` file and the `export async function main()` are **required**. Without them, your agent won't work when deployed or tested with `rf test`. ## 3. Add Memory Enable conversation history so your agent remembers previous messages: ```typescript theme={null} const agent = new Agent({ name: 'My First Agent', instructions: 'You are a helpful assistant. Be concise and friendly.', model: openai('gpt-4o'), memory: { maxTurns: 20, // Remember last 20 messages }, }); ``` Memory is automatically bound to the user you identified with `identify()`. Same user = same conversation history across sessions. ## 4. Add a Tool Tools let your agent perform actions — call APIs, query databases, send messages. Create them in separate files under `tools/`: ```typescript tools/weather.ts theme={null} import { createTool } from '@runflow-ai/sdk'; import { z } from 'zod'; export const weatherTool = createTool({ id: 'get-weather', description: 'Get current weather for a city', inputSchema: z.object({ city: z.string().describe('City name (e.g., "São Paulo")'), }), execute: async (params) => { const geoRes = await fetch( `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(params.city)}&count=1` ); const geo = await geoRes.json(); if (!geo.results?.length) { return { error: `City "${params.city}" not found` }; } const { latitude, longitude, name, country } = geo.results[0]; const weatherRes = await fetch( `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m` ); const weather = await weatherRes.json(); return { city: name, country, temperature: Math.round(weather.current.temperature_2m), unit: 'celsius', }; }, }); ``` Then register it in your agent: ```typescript main.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; import { identify } from '@runflow-ai/sdk/observability'; import { weatherTool } from './tools/weather'; const agent = new Agent({ name: 'My First Agent', instructions: `You are a helpful assistant. ## Tools - Use the weather tool when users ask about temperature or weather conditions - Present results clearly`, model: openai('gpt-4o'), memory: { maxTurns: 20 }, tools: { getWeather: weatherTool, }, }); export async function main(input: any) { identify(input.email || input.phone || 'anonymous'); const result = await agent.process({ message: input.message, sessionId: input.sessionId, }); return { message: result.message, }; } ``` Always mention your tools in the agent's `instructions`. The LLM needs to know **when** to use each tool. Be specific: "Use the weather tool when users ask about temperature or weather conditions." ## 5. Test Locally Run the interactive testing interface: ```bash theme={null} rf test ``` This opens a web UI where you can: * Chat with your agent in real time * See which tools are being called * Inspect memory and trace data * Test conversation continuity ## 6. Add Business Metrics Use `track()` to emit events that power dashboards in the Runflow portal: ```typescript main.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; import { identify, track } from '@runflow-ai/sdk/observability'; import { weatherTool } from './tools/weather'; const agent = new Agent({ name: 'My First Agent', instructions: `You are a helpful assistant. ## Tools - Use the weather tool when users ask about temperature or weather conditions - Present results clearly`, model: openai('gpt-4o'), memory: { maxTurns: 20 }, tools: { getWeather: weatherTool, }, observability: 'full', }); export async function main(input: any) { identify(input.email || input.phone || 'anonymous'); const result = await agent.process({ message: input.message, sessionId: input.sessionId, }); // Track business events for dashboards track('message_processed', { channel: input.channel || 'api', hasTools: result.metadata?.toolsUsed?.length > 0, }); return { message: result.message, }; } ``` ## 7. Deploy When you're ready, deploy to production: ```bash theme={null} rf agents deploy ``` ## Next Steps Learn how to organize your project as it grows Tips for writing effective agents Deep dive into Agents, Memory, Tools, and more See production-ready examples # Common Issues Source: https://docs.runflow.ai/troubleshooting/common-issues Troubleshooting guide for common problems ## API Client Not Configured ``` Error: Runflow API Client is not configured ``` **Solution:** Ensure you have either: 1. Environment variables set (`RUNFLOW_API_KEY`, `RUNFLOW_TENANT_ID`) 2. A `.runflow/rf.json` file 3. Manually configured the API client ```typescript theme={null} import { createRunflowAPIClient, Agent, openai } from '@runflow-ai/sdk'; const apiClient = createRunflowAPIClient({ apiKey: 'your_api_key', tenantId: 'your_tenant_id', }); const agent = new Agent({ name: 'My Agent', instructions: 'Help users', model: openai('gpt-4o'), }); agent._setAPIClient(apiClient); ``` ## Memory Not Persisting **Issue:** Memory is not persisting between sessions **Solution:** Ensure you're passing the same `sessionId` or using `identify()` consistently: ```typescript theme={null} import { identify } from '@runflow-ai/sdk/observability'; // Use identify() for automatic session management identify('+5511999999999'); // OR pass sessionId explicitly await agent.process({ message: 'Hello', sessionId: 'session_456', // Same session ID }); ``` ## Tool Not Being Called **Issue:** Agent is not calling tools even when it should **Solution:** 1. Make sure tool descriptions are clear and specific 2. Use `debug: true` to see what the agent is doing 3. Check that `maxToolIterations` is not set too low ```typescript theme={null} const agent = new Agent({ name: 'My Agent', instructions: 'You MUST use the weather tool when users ask about weather.', model: openai('gpt-4o'), tools: { weather: weatherTool, }, maxToolIterations: 10, // Default debug: true, // Enable debug logging }); ``` ## RAG Not Finding Results **Issue:** Knowledge base search returns no results **Solution:** 1. Check `threshold` value (lower = more results) 2. Increase `k` value for more results 3. Verify vector store name is correct ```typescript theme={null} rag: { vectorStore: 'support-docs', // Verify this exists k: 10, // Increase for more results threshold: 0.5, // Lower for more lenient matching } ``` ## TypeScript Errors **Issue:** TypeScript errors when using the SDK **Solution:** Make sure you're using TypeScript >= 5.0.0 and have proper types: ```bash theme={null} npm install --save-dev typescript@^5.0.0 ``` ```typescript theme={null} // Use proper type imports import type { AgentConfig, AgentInput, AgentOutput } from '@runflow-ai/sdk'; ``` ## Next Steps Review configuration Check API documentation # Abandoned Cart Recovery Source: https://docs.runflow.ai/use-cases/abandoned-cart-recovery A WhatsApp commerce agent that keeps cart state in the KV Store and automatically wins back customers who didn't check out A sales agent that builds shopping carts during WhatsApp conversations, persists them in the **KV Store** (so they survive sessions, restarts and days of silence), and runs an hourly recovery job that finds abandoned carts and follows up — resuming the conversation with full **Memory** context. This is the canonical KV + Memory split: the **cart is business state** (KV), the **conversation is context** (Memory). Neither could do the other's job. ## Project Structure ``` cart-recovery-agent/ ├── main.ts # Webhook entry point (customer messages) ├── recovery.ts # CRON entry point (abandoned cart sweep) ├── agent.ts ├── tools/ │ └── cart-tools.ts ├── .runflow/ │ └── rf.json ├── package.json └── tsconfig.json ``` ## How It Works ``` SCENARIO 1: Customer shops via WhatsApp ──────────────────────────────────────── Customer: "quero 2 camisetas pretas M" → Agent calls add_to_cart → KV set carts/cart:{phone} (ttl: 24h) → Every cart change refreshes the 24h TTL Customer: "fecha o pedido" → Agent calls checkout → payment link → KV delete cart:{phone} SCENARIO 2: Customer goes silent with items in the cart ──────────────────────────────────────── CRON trigger fires every hour → recovery.ts lists carts idle for 2+ hours (KV updatedAt) → Skips carts already reminded (KV namespace "cart-reminders") → For each cart, calls agent.process() with entityType/entityValue → Memory loads → agent sends a natural, personalized nudge → Marks the reminder in KV so the customer is never nudged twice → Carts untouched for 24h simply expire — no cleanup job needed ``` ## Step 1: Cart Tools Backed by the KV Store The cart lives in the `carts` namespace, keyed by the customer's phone. Tools are built with a factory so the key never depends on the LLM getting a phone number right: ```typescript tools/cart-tools.ts theme={null} import { createTool, KV } from '@runflow-ai/sdk'; import { z } from 'zod'; const CART_TTL = 24 * 3600; // cart expires after 24h of inactivity export function createCartTools(phone: string) { const carts = KV.namespace('carts'); const cartKey = `cart:${phone}`; const addToCart = createTool({ id: 'add-to-cart', description: 'Add an item to the customer cart. Use whenever the customer decides on a product.', inputSchema: z.object({ sku: z.string(), name: z.string(), quantity: z.number().min(1), unitPrice: z.number(), }), execute: async (item) => { const cart = (await carts.get<{ items: any[] }>(cartKey)) ?? { items: [] }; cart.items.push(item); // Every write refreshes the TTL — activity keeps the cart alive await carts.set(cartKey, cart, { ttl: CART_TTL }); return { itemCount: cart.items.length }; }, }); const viewCart = createTool({ id: 'view-cart', description: 'Show the current cart contents and total.', inputSchema: z.object({}), execute: async () => { const cart = await carts.get<{ items: any[] }>(cartKey); if (!cart) return { empty: true }; const total = cart.items.reduce((s, i) => s + i.quantity * i.unitPrice, 0); return { items: cart.items, total }; }, }); const checkout = createTool({ id: 'checkout', description: 'Close the order and generate a payment link. Use when the customer confirms the purchase.', inputSchema: z.object({}), execute: async () => { const cart = await carts.get<{ items: any[] }>(cartKey); if (!cart) return { error: 'Cart is empty' }; const order = await createOrder(phone, cart.items); // your order system await carts.delete(cartKey); // cart fulfilled — remove it return { paymentLink: order.paymentLink, orderId: order.id }; }, }); return { addToCart, viewCart, checkout }; } ``` ## Step 2: The Agent ```typescript agent.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; import { createCartTools } from './tools/cart-tools'; export function createSalesAgent(phone: string) { const { addToCart, viewCart, checkout } = createCartTools(phone); return new Agent({ name: 'Sales Agent', instructions: `You sell products for ACME Store via WhatsApp. ## Tools — when to use each - **add-to-cart**: whenever the customer decides on a product and quantity. - **view-cart**: when the customer asks what's in the cart, or before checkout. - **checkout**: when the customer confirms they want to buy. ## Cart recovery follow-ups When asked to recover an abandoned cart, mention the items by name, offer help finishing the order, and keep it short and friendly. Never pressure. One message only.`, model: openai('gpt-4o'), tools: { addToCart, viewCart, checkout }, memory: { maxTurns: 30, summarizeAfter: 20, summarizePrompt: 'Summarize: customer name, products discussed, sizes/preferences, objections.', }, }); } ``` ## Step 3: The Entry Point ```typescript main.ts theme={null} import { identify } from '@runflow-ai/sdk/observability'; import { createSalesAgent } from './agent'; export default async function main(input: { message: string; phone: string }) { // Bind conversation memory to the customer identify(input.phone); const agent = createSalesAgent(input.phone); return agent.process({ message: input.message }); } ``` ## Step 4: The Recovery Sweep A CRON trigger (hourly) scans the `carts` namespace. `listEntries()` returns `updatedAt` for every entry, so finding idle carts requires no extra bookkeeping — the KV Store already tracks the last write: ```typescript recovery.ts theme={null} import { KV } from '@runflow-ai/sdk'; import { createSalesAgent } from './agent'; const IDLE_MS = 2 * 60 * 60 * 1000; // nudge after 2h of silence export default async function recovery() { const carts = KV.namespace('carts'); const reminders = KV.namespace('cart-reminders'); const { items } = await carts.listEntries({ pattern: 'cart:*', limit: 500 }); const cutoff = Date.now() - IDLE_MS; for (const entry of items) { // Not idle long enough yet if (new Date(entry.updatedAt).getTime() > cutoff) continue; // Already reminded — never nudge twice (idempotency via KV) if (await reminders.has(entry.key)) continue; const phone = entry.key.replace('cart:', ''); const itemNames = entry.value.items.map((i: any) => i.name).join(', '); const agent = createSalesAgent(phone); await agent.process({ message: `The customer left these items in the cart 2+ hours ago: ${itemNames}. Send a friendly recovery message.`, entityType: 'phone', entityValue: phone, channel: 'whatsapp', }); // Reminder marker expires with the same window as the cart await reminders.set(entry.key, { at: new Date().toISOString() }, { ttl: 24 * 3600 }); } } ``` Don't call `identify()` inside the loop — it sets a global singleton. Pass `entityType`/`entityValue` directly in `agent.process()`, exactly like the [SDR follow-up](/use-cases/sdr-follow-up) example. ## Key Concepts **Why the cart lives in KV, not Memory.** Memory is conversation context: it gets trimmed by `maxTurns` and compacted by summarization, and it's scoped to the dialogue. A cart parked inside message history could be summarized away, and reading it back would mean parsing prose. In the KV Store the cart is structured data with its own lifecycle — readable by the recovery job, the checkout tool, or a dashboard, independent of any conversation. **Why the follow-up uses Memory.** The nudge is only natural because the agent resumes with full conversation history — the customer's name, the sizes they asked about, the objection they raised. State says *what* is in the cart; Memory says *how the conversation got there*. **TTL as the cleanup policy.** Carts refresh their 24h TTL on every write and silently expire after a day of inactivity. The reminder markers expire on the same window. Nobody writes a cleanup job; expiration *is* the data model. **Idempotent reminders.** The `cart-reminders` namespace is a dedup ledger: `has()` before sending, `set()` after. If the CRON fires twice or overlaps, customers still get at most one nudge. ## Next Steps TTL, namespaces and pattern search reference How memory and identify() work CRON triggers and scheduled callbacks The same pattern applied to lead qualification # Auto-Reviewer Agent (Quality Control) Source: https://docs.runflow.ai/use-cases/auto-reviewer-agent Build an agent that walks another agent's recent executions, runs an LLM judge on each, and writes reviews automatically — replacing manual triage with a daily cron The **auto-reviewer agent** is a small standalone agent whose only job is to evaluate another agent's recent executions and write reviews. It replaces the manual flow of opening the Observability tab and clicking "Evaluate" on every conversation — humans now only see the executions the judge flagged as bad or in need of improvement. This pattern uses three SDK primitives together: [`Executions`](/core-concepts/cross-agent#executions), [`Reviews`](/core-concepts/cross-agent#reviews-full-lifecycle), and the [`LLM`](/core-concepts/llm-standalone) module. ## What you'll build Deployed like any other agent. Has its own ID and code, but never receives user input directly. Fires the reviewer once a day (e.g., 06:00). Could be hourly for high-traffic agents. On each trigger: list recent executions of the target → check if already reviewed (idempotency) → run LLM judge → persist the verdict via `Reviews.create()`. ## Setup You need two things: 1. A **target agent** already in production (the one you want to evaluate). Note its slug or UUID. 2. An **OpenAI API key** (or any LLM provider) — the judge uses it. The reviewer agent itself is a fresh agent. Create it via the portal or `rf create`, then drop in the code below. ## The code ```typescript theme={null} // main.ts of the reviewer agent import { Executions } from '@runflow-ai/sdk/executions'; import { Reviews } from '@runflow-ai/sdk/reviews'; import { LLM } from '@runflow-ai/sdk/llm'; const JUDGE_PROMPT = `You are a strict QA reviewer for a customer-facing AI agent. Read the user's question and the agent's answer, then output ONLY a JSON object on a single line with exactly these fields: rating — "good" | "bad" | "needs_improvement" reason — short Portuguese explanation, at most 240 chars Rubric: - "bad" if the answer is wrong, unsafe, contradicts the user, or hallucinates. - "needs_improvement" if correct but unclear, too short, missing context, or fails to ask a follow-up that the situation clearly required. - "good" if correct, clear, and well-targeted. `; export async function main() { return runCrossAgentReview({ targetAgentId: process.env.TARGET_AGENT_ID ?? 'customer-support', windowHours: 24, limit: 200, }); } async function runCrossAgentReview(opts: { targetAgentId: string; windowHours: number; limit: number; }) { const executions = new Executions(); const reviews = new Reviews(); const judge = LLM.openai('gpt-4o-mini'); // 1. Pull recent executions of the target agent const { data: list } = await executions.list({ agentId: opts.targetAgentId, limit: opts.limit, }); const cutoff = Date.now() - opts.windowHours * 60 * 60 * 1000; let created = 0, skipped = 0, errors = 0; for (const exec of list) { try { // Skip outside the window const startedAtMs = exec.startedAt ? new Date(exec.startedAt).getTime() : Date.now(); if (startedAtMs < cutoff) { skipped++; continue; } // Idempotency — never double-review const { exists } = await reviews.checkHasReview(exec.id); if (exists) { skipped++; continue; } // Drill into the execution to get the actual messages const detail = await executions.get(exec.id); const userMsg = stringify(detail?.input?.message ?? detail?.input); const agentMsg = stringify(detail?.output?.message ?? detail?.output); if (!userMsg || !agentMsg) { skipped++; continue; } // Ask the LLM judge const verdictRaw = await judge.chat( `${JUDGE_PROMPT}\n\nUSER:\n${userMsg}\n\nAGENT:\n${agentMsg}`, ); const verdict = parseVerdict(verdictRaw); if (!verdict) { errors++; continue; } // Persist the review await reviews.create({ executionId: exec.id, agentId: opts.targetAgentId, rating: verdict.rating, comment: verdict.reason, priority: verdict.rating === 'bad' ? 'high' : 'medium', tags: ['auto-judge', 'model:gpt-4o-mini'], }); created++; } catch (err: any) { console.error(`error on exec=${exec.id}: ${err?.message}`); errors++; } } return { scanned: list.length, created, skipped, errors }; } function stringify(v: unknown): string { if (v == null) return ''; if (typeof v === 'string') return v; try { return JSON.stringify(v); } catch { return String(v); } } function parseVerdict(raw: string) { const match = raw.match(/\{[^}]*\}/); if (!match) return null; try { const parsed = JSON.parse(match[0]); const rating = parsed?.rating; const reason = String(parsed?.reason ?? '').slice(0, 240); if (!['good', 'bad', 'needs_improvement'].includes(rating)) return null; if (reason.length < 10) return null; return { rating, reason }; } catch { return null; } } ``` ## Wiring the cron trigger In the portal, on the reviewer agent's Triggers tab: Click **+ Trigger** → choose **CRON**. `0 6 * * *` runs daily at 06:00. For a high-traffic agent, try `0 * * * *` (hourly). The trigger fires `main()` automatically — no input payload needed. ## Auto-dismiss the "good" verdicts (optional) Out of the box every verdict creates a `pending_review` row. To keep the human inbox focused only on `bad` / `needs_improvement`, auto-dismiss the good ones: ```typescript theme={null} if (verdict.rating === 'good') { const { reviews: justCreated } = await reviews.list({ agentId: opts.targetAgentId, status: 'pending_review', limit: 5, }); const fresh = justCreated.find((r) => r.executionId === exec.id); if (fresh) { await reviews.dismiss(fresh.id, { resolutionNotes: 'auto-judge: good — no action needed.', }); } } ``` Drop this block right after the `reviews.create()` call inside the `for` loop. ## Why this works The reviewer agent's API key only sees its own tenant. Cross-tenant references return 404 — no risk of evaluating someone else's data. `checkHasReview()` prevents double-reviewing. Re-run the cron as often as you want — already-reviewed executions are skipped silently. Reviews stamped by the SDK show up in the UI as `reviewedBy: apikey:`. Easy to filter from human reviewers. Lives in its own agent. The target agent doesn't know it's being reviewed — zero coupling, change one without touching the other. ## Cost model * **Per execution review:** 1 LLM call to the judge (\~\$0.0001 with `gpt-4o-mini`) * **Per cron run:** N LLM calls for N un-reviewed executions in the window * For 1000 executions/day, the daily cost is \< \$0.10 The dominant cost is the LLM judge, not the SDK round-trips. ## Variants Replace `reviews.create()` with a wrapper that also POSTs to a Slack webhook when `verdict.rating === 'bad'`. You get an auto-curated inbox **and** real-time alerts. Filter resolved reviews with `correctedOutput` and export them via `reviews.exportForTraining({ agentId, status: 'resolved' })` — OpenAI fine-tuning format. Lets you close the loop: reviewer flags → human corrects → model gets retrained on the corrections. Loop `runCrossAgentReview` over a list of target agents. Each cron firing evaluates the entire fleet. Use `agents.list()` to discover targets dynamically. ## Next steps Full reference of the primitives this use case uses. More on cron triggers and scheduling patterns. `Reviews`, `Executions`, and `LLM` reference. What goes into the executions and traces this reviewer reads. # Collections Agent (WhatsApp) Source: https://docs.runflow.ai/use-cases/collections-agent Build a debt collection agent with WhatsApp integration, categorization, and business metrics A debt collection agent that processes WhatsApp messages, categorizes conversation outcomes, and tracks collection metrics. This example is based on real production patterns and shows how to handle webhook input transformation, phone-based identification, and intelligent categorization. ## Project Structure ``` collections-agent/ ├── main.ts ├── agent.ts ├── tools/ │ ├── index.ts │ └── finalize-conversation.ts ├── prompts/ │ └── index.ts ├── config/ │ └── settings.ts ├── .runflow/ │ └── rf.json ├── package.json └── tsconfig.json ``` ## Step 1: Configuration Define your categories, priorities, and constants: ```typescript config/settings.ts theme={null} export const CONVERSATION_OUTCOMES = [ 'PAYMENT_PROMISED', 'PAYMENT_PLAN_ACCEPTED', 'ALREADY_PAID', 'DISPUTE', 'WRONG_NUMBER', 'NO_RESPONSE', 'REFUSED_TO_PAY', 'REQUESTED_CALLBACK', ] as const; export type ConversationOutcome = (typeof CONVERSATION_OUTCOMES)[number]; export const OUTCOME_PRIORITIES: Record = { PAYMENT_PROMISED: 'high', PAYMENT_PLAN_ACCEPTED: 'high', ALREADY_PAID: 'medium', DISPUTE: 'high', WRONG_NUMBER: 'low', NO_RESPONSE: 'low', REFUSED_TO_PAY: 'medium', REQUESTED_CALLBACK: 'medium', }; export const AGENT_CONFIG = { model: 'gpt-4o', temperature: 0, memoryMaxTurns: 50, }; ``` ## Step 2: Prompt ```typescript prompts/index.ts theme={null} export const collectionsPrompt = `You are a professional and empathetic debt collection agent. ## Behavior - Always be respectful and never aggressive - Understand the customer's situation before proposing solutions - Offer payment plan options when appropriate - If the customer already paid, acknowledge and thank them - If it's the wrong number, apologize and end the conversation ## Tools - Use **finalize-conversation** when the conversation reaches a conclusion: - Customer promised to pay - Customer accepted a payment plan - Customer disputes the debt - Customer says it's wrong number - Customer explicitly refuses to pay - Always include a summary of the conversation when finalizing ## Important Rules - Never threaten the customer - Never share the debt amount with third parties - If the customer is upset, acknowledge their feelings - Maximum 3 attempts to negotiate before offering to schedule a callback`; ``` ## Step 3: Categorization Tool The main tool categorizes conversation outcomes and tracks metrics: ```typescript tools/finalize-conversation.ts theme={null} import { createTool } from '@runflow-ai/sdk'; import { track } from '@runflow-ai/sdk/observability'; import { z } from 'zod'; import { CONVERSATION_OUTCOMES, OUTCOME_PRIORITIES } from '../config/settings'; export const finalizeConversationTool = createTool({ id: 'finalize-conversation', description: 'Finalize a collection conversation with outcome categorization', inputSchema: z.object({ outcome: z.enum(CONVERSATION_OUTCOMES).describe('The conversation outcome'), summary: z.string().describe('Brief summary of the conversation'), paymentDate: z.string().optional().describe('Promised payment date, if applicable'), amount: z.number().optional().describe('Agreed payment amount, if applicable'), notes: z.string().optional().describe('Additional observations'), }), execute: async (params) => { const priority = OUTCOME_PRIORITIES[params.outcome]; // Track business metrics track('collection_finalized', { outcome: params.outcome, priority, hasPaymentDate: !!params.paymentDate, amount: params.amount, }); return { success: true, outcome: params.outcome, priority, summary: params.summary, paymentDate: params.paymentDate, nextAction: getNextAction(params.outcome), }; }, }); function getNextAction(outcome: string): string { const actions: Record = { PAYMENT_PROMISED: 'Schedule follow-up for payment date', PAYMENT_PLAN_ACCEPTED: 'Send payment plan details via email', ALREADY_PAID: 'Verify payment in system and close case', DISPUTE: 'Escalate to legal team', WRONG_NUMBER: 'Remove number from contact list', NO_RESPONSE: 'Schedule retry in 48 hours', REFUSED_TO_PAY: 'Escalate to supervisor', REQUESTED_CALLBACK: 'Schedule callback at requested time', }; return actions[outcome] || 'Review manually'; } ``` ```typescript tools/index.ts theme={null} export { finalizeConversationTool } from './finalize-conversation'; ``` ## Step 4: Agent Definition ```typescript agent.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; import { collectionsPrompt } from './prompts'; import { finalizeConversationTool } from './tools'; import { AGENT_CONFIG } from './config/settings'; export const collectionsAgent = new Agent({ name: 'Collections Agent', instructions: collectionsPrompt, model: openai(AGENT_CONFIG.model), memory: { maxTurns: AGENT_CONFIG.memoryMaxTurns, }, tools: { finalizeConversation: finalizeConversationTool, }, modelConfig: { temperature: AGENT_CONFIG.temperature, }, observability: 'full', }); ``` ## Step 5: Main Entry Point with Webhook Parsing The `main.ts` handles input transformation from WhatsApp webhooks, user identification by phone, and response formatting: ```typescript main.ts theme={null} import { identify, track } from '@runflow-ai/sdk/observability'; import { collectionsAgent } from './agent'; // Parse webhook input from WhatsApp/Zenvia/Twilio function parseWebhookInput(input: any) { // Zenvia format if (input.message?.from) { return { phone: input.message.from, message: input.message.contents?.[0]?.text || input.message.text || '', channel: 'zenvia', }; } // Twilio format if (input.From && input.Body) { return { phone: input.From, message: input.Body, channel: 'twilio', }; } // Direct API call return { phone: input.phone || input.from, message: input.message, channel: input.channel || 'api', }; } export async function main(input: any) { const { phone, message, channel } = parseWebhookInput(input); if (!message || !phone) { return { error: 'message and phone are required' }; } // Identify by phone number — memory is bound to this number identify(phone); // Track incoming message track('collection_message_received', { channel }); try { const result = await collectionsAgent.process({ message, sessionId: `collection_${phone}`, }); return { message: result.message, phone, metadata: result.metadata, }; } catch (error) { console.error('[collections-agent] Error:', error); return { error: 'An error occurred processing the message' }; } } ``` ## Key Patterns ### Phone-Based Identification In WhatsApp/phone integrations, the phone number is the natural user identifier. Memory persists across all conversations with the same number: ```typescript theme={null} identify('+5511999999999'); // All subsequent agent.process() calls use this context // Memory is bound to this phone number ``` ### Webhook Input Transformation Real integrations receive data in different formats (Zenvia, Twilio, custom APIs). Always normalize the input before processing: ```typescript theme={null} function parseWebhookInput(input: any) { // Handle multiple formats // Return a consistent { phone, message, channel } object } ``` ### Outcome Categorization Using TypeScript enums for conversation outcomes keeps your code type-safe and makes it easy to build dashboards: ```typescript theme={null} // Strongly typed outcomes export const OUTCOMES = ['PAYMENT_PROMISED', 'DISPUTE', ...] as const; export type Outcome = (typeof OUTCOMES)[number]; // Each outcome maps to a priority and next action export const PRIORITIES: Record = { ... }; ``` ## Next Steps Support agent with knowledge base Learn about identify patterns Track business metrics Tips for effective agents # Customer Onboarding Assistant Source: https://docs.runflow.ai/use-cases/customer-onboarding Build an interactive onboarding agent that guides users step by step and tracks progress An interactive onboarding agent that guides new users through setup, tracks their progress, and adapts to their pace. This example shows long-running conversations with progress tracking, knowledge base search, and dynamic instructions based on user state. ## Project Structure ``` onboarding-agent/ ├── main.ts ├── agent.ts ├── tools/ │ ├── index.ts │ ├── mark-step-complete.ts │ └── get-progress.ts ├── prompts/ │ └── index.ts ├── config/ │ └── steps.ts ├── .runflow/ │ └── rf.json ├── package.json └── tsconfig.json ``` ## Step 1: Define Onboarding Steps Keep step definitions in a config file so they're easy to update: ```typescript config/steps.ts theme={null} export const ONBOARDING_STEPS = [ { id: 'profile', name: 'Complete your profile', description: 'Set up name, photo, and preferences' }, { id: 'workspace', name: 'Create a workspace', description: 'Set up your first workspace' }, { id: 'invite', name: 'Invite team members', description: 'Add at least one teammate' }, { id: 'first-agent', name: 'Create your first agent', description: 'Build and test a simple agent' }, { id: 'deploy', name: 'Deploy to production', description: 'Deploy your agent live' }, ] as const; export type StepId = (typeof ONBOARDING_STEPS)[number]['id']; ``` ## Step 2: Progress Tools Tools for tracking and querying onboarding progress: ```typescript tools/get-progress.ts theme={null} import { createTool } from '@runflow-ai/sdk'; import { z } from 'zod'; import { ONBOARDING_STEPS } from '../config/steps'; export const getProgressTool = createTool({ id: 'get-progress', description: 'Get the current onboarding progress for a user', inputSchema: z.object({}), execute: async (params, toolContext) => { // Fetch from your database const completed = await fetchCompletedSteps(toolContext.userId); const steps = ONBOARDING_STEPS.map((step) => ({ ...step, completed: completed.includes(step.id), })); const completedCount = steps.filter((s) => s.completed).length; const nextStep = steps.find((s) => !s.completed); return { steps, completedCount, totalSteps: steps.length, progress: `${completedCount}/${steps.length}`, nextStep: nextStep || null, isComplete: completedCount === steps.length, }; }, }); ``` ```typescript tools/mark-step-complete.ts theme={null} import { createTool } from '@runflow-ai/sdk'; import { track } from '@runflow-ai/sdk/observability'; import { z } from 'zod'; import { ONBOARDING_STEPS } from '../config/steps'; const validStepIds = ONBOARDING_STEPS.map((s) => s.id) as [string, ...string[]]; export const markStepCompleteTool = createTool({ id: 'mark-step-complete', description: 'Mark an onboarding step as completed', inputSchema: z.object({ stepId: z.enum(validStepIds).describe('The step to mark as complete'), notes: z.string().optional().describe('Optional notes about completion'), }), execute: async (params, toolContext) => { try { // Check if already completed const completed = await fetchCompletedSteps(toolContext.userId); if (completed.includes(params.stepId)) { return { success: true, alreadyCompleted: true, stepId: params.stepId }; } // Mark as completed await saveStepCompletion(toolContext.userId, params.stepId, params.notes); const newCompleted = completed.length + 1; const total = ONBOARDING_STEPS.length; track('onboarding_step_completed', { stepId: params.stepId, progress: `${newCompleted}/${total}`, }); // Check if onboarding is now complete if (newCompleted === total) { track('onboarding_completed', { totalSteps: total, }); } return { success: true, stepId: params.stepId, progress: `${newCompleted}/${total}`, isOnboardingComplete: newCompleted === total, }; } catch (error) { return { success: false, error: 'Failed to save progress' }; } }, }); ``` ```typescript tools/index.ts theme={null} export { getProgressTool } from './get-progress'; export { markStepCompleteTool } from './mark-step-complete'; ``` ## Step 3: Prompt ```typescript prompts/index.ts theme={null} import { ONBOARDING_STEPS } from '../config/steps'; const stepList = ONBOARDING_STEPS.map((s, i) => `${i + 1}. **${s.name}**: ${s.description}`).join('\n'); export const onboardingPrompt = `You are a friendly onboarding assistant that guides new users through setup. ## Onboarding Steps ${stepList} ## Behavior - Start by checking the user's current progress with get-progress - Guide them through the next incomplete step - Celebrate when they complete a step — use encouragement - If they're stuck, offer tips and link to relevant docs - If they ask about something unrelated, gently steer back to onboarding - Adapt your pace — if they seem experienced, be brief; if they seem new, explain more ## Tools - Use **get-progress** at the start of each conversation to know where they are - Use **mark-step-complete** when the user confirms they've finished a step - Never mark a step as complete without the user confirming it ## Response Style - Be warm and encouraging but not over-the-top - Use short paragraphs - Use numbered steps when explaining how to do something - End messages with a clear next action`; ``` ## Step 4: Agent Definition ```typescript agent.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; import { onboardingPrompt } from './prompts'; import { getProgressTool, markStepCompleteTool } from './tools'; export const onboardingAgent = new Agent({ name: 'Onboarding Assistant', instructions: onboardingPrompt, model: openai('gpt-4o'), memory: { maxTurns: 50, summarizeAfter: 30, summarizePrompt: 'Summarize: completed onboarding steps, current step, user questions, and blockers', }, rag: { vectorStore: 'onboarding-docs', k: 3, threshold: 0.7, searchPrompt: `Search when the user asks how to do something specific, like: - How to create a workspace - How to invite team members - How to deploy an agent`, }, tools: { getProgress: getProgressTool, markStepComplete: markStepCompleteTool, }, observability: 'full', }); ``` ## Step 5: Main Entry Point ```typescript main.ts theme={null} import { identify, track } from '@runflow-ai/sdk/observability'; import { onboardingAgent } from './agent'; export async function main(input: any) { if (!input?.message) { return { error: 'message is required' }; } const userId = input.email || input.userId; if (!userId) { return { error: 'email or userId is required for onboarding' }; } identify(userId); try { const result = await onboardingAgent.process({ message: input.message, sessionId: `onboarding_${userId}`, }); track('onboarding_interaction', { channel: input.channel || 'api', }); return { message: result.message }; } catch (error) { console.error('[onboarding] Error:', error); return { error: 'Something went wrong. Please try again.' }; } } ``` ## Key Patterns ### Long-Running Conversations Onboarding happens over days or weeks. Use high `maxTurns` and `summarizeAfter` to preserve context without losing important progress information: ```typescript theme={null} memory: { maxTurns: 50, summarizeAfter: 30, summarizePrompt: 'Summarize: completed steps, current step, user questions, blockers', } ``` ### Progress-Aware Agent The agent calls `get-progress` at the start of each conversation to know exactly where the user is. This means it can pick up right where they left off — even days later. ### Step Validation with Enums Using TypeScript enums for step IDs ensures the LLM can only mark valid steps as complete: ```typescript theme={null} const validStepIds = ONBOARDING_STEPS.map((s) => s.id) as [string, ...string[]]; inputSchema: z.object({ stepId: z.enum(validStepIds), // LLM can only choose from valid steps }) ``` ## Next Steps Long conversation management Power the docs search Support agent example Tips for effective agents # Customer Support Agent with RAG Source: https://docs.runflow.ai/use-cases/customer-support-rag Build a complete support agent with knowledge base search, tools, and business metrics A complete customer support agent that searches your documentation, creates tickets, and tracks resolution metrics. This example shows the full project structure with separate files for tools and prompts. ## Project Structure ``` support-agent/ ├── main.ts ├── agent.ts ├── tools/ │ ├── index.ts │ ├── create-ticket.ts │ └── search-orders.ts ├── prompts/ │ └── index.ts ├── .runflow/ │ └── rf.json ├── package.json └── tsconfig.json ``` ## Step 1: Define Your Prompt Start with well-structured instructions that tell the agent how to behave, when to use tools, and how to respond. ```typescript prompts/index.ts theme={null} export const supportPrompt = `You are a customer support agent for ACME Corp. ## Behavior - Always be professional, empathetic, and solution-oriented - Respond in the customer's language - Search the knowledge base before answering technical questions - If you don't know something, say so honestly — never guess ## Tools - Use **search-orders** when customers ask about order status, delivery, or tracking - Use **create-ticket** when: - The issue cannot be resolved in this conversation - The customer needs a refund or account change - Always confirm with the customer before creating a ticket - Set priority: 'high' if customer is blocked, 'medium' for inconveniences, 'low' for feature requests ## Knowledge Base - The knowledge base contains product documentation, FAQs, and policies - Always search it when customers ask about features, pricing, or processes - Quote relevant information when answering ## Response Format - Be concise (2-3 paragraphs max) - Use bullet points for step-by-step instructions - Always confirm actions taken ("I've created ticket #123 for you") - End with a follow-up question when appropriate`; ``` ## Step 2: Create Your Tools Each tool lives in its own file with clear input validation and structured responses. ```typescript tools/search-orders.ts theme={null} import { createTool } from '@runflow-ai/sdk'; import { z } from 'zod'; export const searchOrdersTool = createTool({ id: 'search-orders', description: 'Search customer orders by order ID or customer email', inputSchema: z.object({ orderId: z.string().optional().describe('Order ID (e.g., ORD-12345)'), customerEmail: z.string().email().optional().describe('Customer email'), }), execute: async (params, toolContext) => { try { // Example: query your database or API const response = await fetch( `https://api.yourcompany.com/orders?id=${params.orderId || ''}&email=${params.customerEmail || ''}`, { headers: { 'Authorization': `Bearer ${process.env.ORDERS_API_KEY}` } } ); if (!response.ok) { return { found: false, error: 'Failed to search orders' }; } const orders = await response.json(); if (!orders.length) { return { found: false, query: params.orderId || params.customerEmail }; } return { found: true, orders: orders.map((o: any) => ({ id: o.id, status: o.status, total: o.total, createdAt: o.createdAt, estimatedDelivery: o.estimatedDelivery, })), }; } catch (error) { return { found: false, error: 'Service temporarily unavailable' }; } }, }); ``` ```typescript tools/create-ticket.ts theme={null} import { createTool } from '@runflow-ai/sdk'; import { track } from '@runflow-ai/sdk/observability'; import { z } from 'zod'; export const createTicketTool = createTool({ id: 'create-ticket', description: 'Create a support ticket for issues that need human follow-up', inputSchema: z.object({ subject: z.string().describe('Brief description of the issue'), description: z.string().describe('Detailed description with context'), priority: z.enum(['low', 'medium', 'high']).describe('Issue priority'), }), execute: async (params, toolContext) => { try { // Example: create ticket via connector or API const ticket = await toolContext.connector('hubspot', 'create-ticket', { subject: params.subject, content: params.description, priority: params.priority, }); // Track for business metrics track('ticket_created', { priority: params.priority, subject: params.subject, }); return { success: true, ticketId: ticket.id, message: `Ticket ${ticket.id} created successfully`, }; } catch (error) { return { success: false, error: 'Failed to create ticket. Please try again.', }; } }, }); ``` Re-export everything from an index file: ```typescript tools/index.ts theme={null} export { searchOrdersTool } from './search-orders'; export { createTicketTool } from './create-ticket'; ``` ## Step 3: Configure the Agent ```typescript agent.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; import { supportPrompt } from './prompts'; import { searchOrdersTool, createTicketTool } from './tools'; export const supportAgent = new Agent({ name: 'Customer Support', instructions: supportPrompt, model: openai('gpt-4o'), memory: { maxTurns: 20, summarizeAfter: 15, summarizePrompt: 'Summarize key issues, actions taken, and pending items', }, rag: { vectorStore: 'support-docs', k: 5, threshold: 0.7, searchPrompt: `Search the knowledge base when the customer asks about: - Product features or how things work - Pricing or plan details - Policies (refund, cancellation, etc.) - Technical troubleshooting steps`, }, tools: { searchOrders: searchOrdersTool, createTicket: createTicketTool, }, observability: 'full', }); ``` ## Step 4: Wire Everything in `main.ts` ```typescript main.ts theme={null} import { identify, track } from '@runflow-ai/sdk/observability'; import { supportAgent } from './agent'; export async function main(input: any) { // Validate input if (!input?.message || typeof input.message !== 'string') { return { error: 'message is required' }; } // Identify the customer identify(input.email || input.phone || input.userId || 'anonymous'); try { const result = await supportAgent.process({ message: input.message, sessionId: input.sessionId, }); // Track support metrics track('support_request', { channel: input.channel || 'api', resolved: !result.metadata?.toolsUsed?.includes('create-ticket'), }); return { message: result.message, metadata: result.metadata, }; } catch (error) { console.error('[support-agent] Error:', error); return { error: 'An error occurred. Please try again.' }; } } ``` ## Setting Up the Knowledge Base Upload your documentation to power RAG: ```bash theme={null} # Create the knowledge base rf kb create support-docs # Upload files rf kb upload support-docs ./docs/faq.md rf kb upload support-docs ./docs/pricing.md rf kb upload support-docs ./docs/refund-policy.md # Or upload an entire directory rf kb upload support-docs ./docs/ # Test search rf kb search support-docs "How do I cancel my subscription?" ``` ## Key Takeaways * **Separate concerns**: tools, prompts, and agent config in their own files * **Always identify**: call `identify()` before `agent.process()` for proper memory and tracing * **Track metrics**: use `track()` in tools and main.ts to power dashboards * **Handle errors**: validate input, try/catch in main, return error objects from tools * **Write specific instructions**: tell the agent exactly when and how to use each tool * **Use RAG wisely**: write a specific `searchPrompt` so the agent knows when to search ## Next Steps WhatsApp collections example Learn more about RAG Tips for effective agents Organize your project # Feedback Analysis System Source: https://docs.runflow.ai/use-cases/feedback-analysis Build an automated pipeline that analyzes sentiment, categorizes feedback, and creates tickets An automated feedback processing pipeline that analyzes sentiment with AI, categorizes issues, and creates tickets for negative feedback. This example shows how to combine workflows with agents and connectors for a hands-off analysis system. ## Project Structure ``` feedback-analysis/ ├── main.ts ├── workflows/ │ └── analyze-feedback.ts ├── agents/ │ ├── sentiment.ts │ └── action-recommender.ts ├── config/ │ └── settings.ts ├── .runflow/ │ └── rf.json ├── package.json └── tsconfig.json ``` ## Step 1: Configuration ```typescript config/settings.ts theme={null} export const FEEDBACK_CATEGORIES = [ 'product_bug', 'feature_request', 'ux_issue', 'performance', 'documentation', 'pricing', 'support_quality', 'praise', 'other', ] as const; export type FeedbackCategory = (typeof FEEDBACK_CATEGORIES)[number]; export const SENTIMENT_THRESHOLDS = { negative: -0.3, // Below this = negative positive: 0.3, // Above this = positive }; ``` ## Step 2: Analysis Agents ### Sentiment Analyzer Cheap, fast model for classification: ```typescript agents/sentiment.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; export const sentimentAgent = new Agent({ name: 'Sentiment Analyzer', instructions: `You analyze customer feedback for sentiment and categorization. ## Task Analyze the feedback and return a structured analysis. ## Response Format Respond with valid JSON only: { "sentiment": "positive" | "neutral" | "negative", "score": , "category": "", "themes": ["", ""], "summary": "" }`, model: openai('gpt-4o-mini'), modelConfig: { temperature: 0 }, }); ``` ### Action Recommender Better model for nuanced recommendations: ```typescript agents/action-recommender.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; export const actionAgent = new Agent({ name: 'Action Recommender', instructions: `You recommend specific actions based on customer feedback analysis. ## Task Given the original feedback and sentiment analysis, recommend concrete actions. ## Response Format Respond with valid JSON: { "priority": "critical" | "high" | "medium" | "low", "actions": [ { "action": "", "owner": "" } ], "shouldFollowUp": , "followUpMessage": "" }`, model: openai('gpt-4o'), modelConfig: { temperature: 0.3 }, }); ``` ## Step 3: Workflow The workflow chains analysis steps and conditionally creates tickets: ```typescript workflows/analyze-feedback.ts theme={null} import { createWorkflow } from '@runflow-ai/sdk'; import { z } from 'zod'; import { sentimentAgent } from '../agents/sentiment'; import { actionAgent } from '../agents/action-recommender'; export const analyzeFeedbackWorkflow = createWorkflow({ id: 'analyze-feedback', inputSchema: z.object({ feedback: z.string(), customerEmail: z.string(), customerName: z.string(), source: z.string(), }), outputSchema: z.any(), }) // Step 1: Analyze sentiment .agent('analyze', sentimentAgent, { promptTemplate: `Analyze this customer feedback: "{{input.feedback}}" Customer: {{input.customerName}} ({{input.customerEmail}}) Source: {{input.source}}`, }) // Step 2: Recommend actions .agent('recommend', actionAgent, { promptTemplate: `Original feedback: "{{input.feedback}}" Customer: {{input.customerName}} Sentiment analysis: {{analyze.text}} What actions should we take?`, }) // Step 3: Create ticket if negative .condition( 'check-negative', (ctx) => { try { const analysis = JSON.parse(ctx.stepResults.get('analyze').text); return analysis.sentiment === 'negative'; } catch { return false; } }, // Negative path: create ticket [ { id: 'create-ticket', type: 'connector', config: { connector: 'hubspot', resource: 'tickets', action: 'create', parameters: { subject: 'Negative Feedback — {{input.customerName}}', content: `Feedback: {{input.feedback}} Analysis: {{analyze.text}} Recommended actions: {{recommend.text}}`, priority: 'high', category: 'feedback', }, }, }, ], // Positive/neutral path: log only [ { id: 'log-feedback', type: 'function', config: { execute: async (input, ctx) => { return { logged: true, sentiment: 'positive_or_neutral' }; }, }, }, ] ) .build(); ``` ## Step 4: Main Entry Point ```typescript main.ts theme={null} import { identify, track } from '@runflow-ai/sdk/observability'; import { analyzeFeedbackWorkflow } from './workflows/analyze-feedback'; export async function main(input: any) { // Validate if (!input?.feedback) { return { error: 'feedback is required' }; } identify(input.customerEmail || input.customerId || 'anonymous'); try { const result = await analyzeFeedbackWorkflow.execute({ feedback: input.feedback, customerEmail: input.customerEmail || '', customerName: input.customerName || 'Unknown', source: input.source || 'api', }); // Parse results for metrics let sentiment = 'unknown'; let category = 'unknown'; try { const analysis = JSON.parse(result.stepResults?.analyze?.text || '{}'); sentiment = analysis.sentiment; category = analysis.category; } catch {} // Track feedback metrics track('feedback_analyzed', { sentiment, category, source: input.source || 'api', ticketCreated: sentiment === 'negative', }); return { message: `Feedback analyzed: ${sentiment} sentiment, category: ${category}`, analysis: result.stepResults?.analyze?.text, actions: result.stepResults?.recommend?.text, ticketCreated: sentiment === 'negative', }; } catch (error) { console.error('[feedback-analysis] Error:', error); return { error: 'Failed to analyze feedback' }; } } ``` ## Triggering the Pipeline This workflow is typically triggered by external events, not user conversations: ```bash theme={null} # From a webhook (NPS survey, support form, review platform) curl -X POST https://your-agent.runflow.ai/api \ -H "Content-Type: application/json" \ -d '{ "feedback": "The product crashes every time I try to export. Very frustrated.", "customerEmail": "jane@example.com", "customerName": "Jane Doe", "source": "nps_survey" }' ``` ## How It Works ``` Feedback arrives (webhook, form, API) ↓ ┌────────────────────┐ │ Sentiment Agent │ gpt-4o-mini → sentiment, category, themes └──────┬─────────────┘ ↓ ┌────────────────────┐ │ Action Agent │ gpt-4o → priority, actions, follow-up └──────┬─────────────┘ ↓ Negative? ╱ ╲ Yes No ↓ ↓ Create Log for HubSpot analytics ticket ↓ Track metrics ``` ## Key Patterns ### Pipeline vs Conversation This is a **workflow** (pipeline), not an agent (conversation). Data flows in, gets processed, and comes out — no back-and-forth with a user. Workflows are ideal for batch processing and event-driven automations. ### Cheap Classification, Quality Recommendations The sentiment agent uses `gpt-4o-mini` (fast, cheap). The action recommender uses `gpt-4o` (better reasoning). Match model cost to task complexity. ### Structured JSON for Reliable Branching Both agents return structured JSON so the workflow can reliably branch on results: ```typescript theme={null} // Agent returns: { "sentiment": "negative", ... } // Workflow condition parses and branches: .condition('check', (ctx) => { const analysis = JSON.parse(ctx.stepResults.get('analyze').text); return analysis.sentiment === 'negative'; }) ``` ## Next Steps Learn more about workflows HubSpot, Slack integrations Supervisor pattern Track business metrics # Multi-Agent System (Supervisor Pattern) Source: https://docs.runflow.ai/use-cases/multi-agent-system Build a supervisor that routes requests to specialized agents based on intent A multi-agent system where a supervisor agent analyzes incoming messages and routes them to specialized agents — sales, support, or billing. Each specialist has its own tools, knowledge base, and personality. This pattern is ideal when a single agent can't handle all scenarios well. ## Project Structure ``` multi-agent/ ├── main.ts ├── supervisor.ts ├── agents/ │ ├── sales.ts │ ├── support.ts │ └── billing.ts ├── tools/ │ ├── index.ts │ ├── search-orders.ts │ ├── create-ticket.ts │ └── check-invoice.ts ├── prompts/ │ └── index.ts ├── .runflow/ │ └── rf.json ├── package.json └── tsconfig.json ``` ## Step 1: Specialist Agents Each specialist handles a specific domain with its own tools and instructions. ### Sales Agent ```typescript agents/sales.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; export const salesAgentConfig = { name: 'Sales Specialist', instructions: `You are a sales specialist for ACME Corp. ## Behavior - Help with pricing questions, plan comparisons, and purchasing - Be consultative — understand needs before recommending plans - For Enterprise inquiries, offer to schedule a demo - Always provide clear pricing information ## Response Style - Be enthusiastic but not pushy - Use concrete numbers and comparisons - End with a clear next step`, model: openai('gpt-4o'), }; ``` ### Support Agent ```typescript agents/support.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; export const supportAgentConfig = { name: 'Technical Support', instructions: `You are a technical support specialist for ACME Corp. ## Behavior - Search the knowledge base before answering technical questions - Walk users through solutions step by step - Create a ticket if the issue can't be resolved here - Always confirm the user's problem before suggesting solutions ## Tools - Use **search-orders** to look up order details - Use **create-ticket** for issues needing human follow-up`, model: openai('gpt-4o'), }; ``` ### Billing Agent ```typescript agents/billing.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; export const billingAgentConfig = { name: 'Billing Specialist', instructions: `You are a billing specialist for ACME Corp. ## Behavior - Help with invoices, payment issues, and subscription changes - Always verify the account before making changes - For refund requests, check the policy first, then proceed ## Tools - Use **check-invoice** to look up invoice details - Use **create-ticket** for complex billing issues that need manual review`, model: openai('gpt-4o'), }; ``` ## Step 2: Supervisor Agent The supervisor uses the built-in `agents` config to automatically route to specialists: ```typescript supervisor.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; import { salesAgentConfig } from './agents/sales'; import { supportAgentConfig } from './agents/support'; import { billingAgentConfig } from './agents/billing'; import { searchOrdersTool, createTicketTool, checkInvoiceTool } from './tools'; export const supervisorAgent = new Agent({ name: 'Customer Service Supervisor', instructions: `You route customer requests to the right specialist. ## Routing Rules - **Sales**: pricing, plans, purchasing, upgrades, demos, discounts - **Support**: technical issues, bugs, how-to questions, order status - **Billing**: invoices, payments, refunds, subscription changes, charges ## Behavior - Analyze the customer's message to determine intent - Route to the most appropriate specialist - If the intent is ambiguous, ask the customer to clarify - If a conversation switches topics (e.g., support → billing), re-route ## Important - You do NOT answer questions directly — you route to specialists - Never make up information about pricing, policies, or account details`, model: openai('gpt-4o-mini'), // Cheap model for routing // Specialist agents — Runflow handles routing automatically agents: { sales: salesAgentConfig, support: { ...supportAgentConfig, tools: { searchOrders: searchOrdersTool, createTicket: createTicketTool, }, rag: { vectorStore: 'support-docs', k: 5, threshold: 0.7, }, }, billing: { ...billingAgentConfig, tools: { checkInvoice: checkInvoiceTool, createTicket: createTicketTool, }, }, }, memory: { maxTurns: 30, summarizeAfter: 20, summarizePrompt: 'Summarize: customer intent, which specialist handled it, actions taken, pending issues', }, observability: 'full', }); ``` ## Step 3: Tools Shared tools used by multiple specialists: ```typescript tools/search-orders.ts theme={null} import { createTool } from '@runflow-ai/sdk'; import { z } from 'zod'; export const searchOrdersTool = createTool({ id: 'search-orders', description: 'Search customer orders by order ID or email', inputSchema: z.object({ orderId: z.string().optional().describe('Order ID (e.g., ORD-12345)'), email: z.string().email().optional().describe('Customer email'), }), execute: async (params) => { try { const response = await fetch( `https://api.yourcompany.com/orders?id=${params.orderId || ''}&email=${params.email || ''}`, { headers: { 'Authorization': `Bearer ${process.env.ORDERS_API_KEY}` } } ); const orders = await response.json(); if (!orders.length) { return { found: false, query: params.orderId || params.email }; } return { found: true, orders: orders.map((o: any) => ({ id: o.id, status: o.status, total: o.total, createdAt: o.createdAt, })), }; } catch { return { found: false, error: 'Service unavailable' }; } }, }); ``` ```typescript tools/check-invoice.ts theme={null} import { createTool } from '@runflow-ai/sdk'; import { z } from 'zod'; export const checkInvoiceTool = createTool({ id: 'check-invoice', description: 'Look up invoice details by invoice ID or customer email', inputSchema: z.object({ invoiceId: z.string().optional().describe('Invoice ID'), email: z.string().email().optional().describe('Customer email'), }), execute: async (params) => { try { const invoices = await fetchInvoices(params.invoiceId, params.email); if (!invoices.length) { return { found: false }; } return { found: true, invoices: invoices.map((inv: any) => ({ id: inv.id, amount: inv.amount, status: inv.status, dueDate: inv.dueDate, paidAt: inv.paidAt, })), }; } catch { return { found: false, error: 'Could not retrieve invoices' }; } }, }); ``` ```typescript tools/create-ticket.ts theme={null} import { createTool } from '@runflow-ai/sdk'; import { track } from '@runflow-ai/sdk/observability'; import { z } from 'zod'; export const createTicketTool = createTool({ id: 'create-ticket', description: 'Create a support ticket for issues needing human follow-up', inputSchema: z.object({ subject: z.string(), description: z.string(), priority: z.enum(['low', 'medium', 'high']), department: z.enum(['sales', 'support', 'billing']), }), execute: async (params) => { try { const ticket = { id: `TICKET-${Date.now()}`, ...params }; track('ticket_created', { department: params.department, priority: params.priority, }); return { success: true, ticketId: ticket.id }; } catch { return { success: false, error: 'Failed to create ticket' }; } }, }); ``` ```typescript tools/index.ts theme={null} export { searchOrdersTool } from './search-orders'; export { createTicketTool } from './create-ticket'; export { checkInvoiceTool } from './check-invoice'; ``` ## Step 4: Main Entry Point ```typescript main.ts theme={null} import { identify, track } from '@runflow-ai/sdk/observability'; import { supervisorAgent } from './supervisor'; export async function main(input: any) { if (!input?.message) { return { error: 'message is required' }; } identify(input.email || input.phone || input.userId || 'anonymous'); try { const result = await supervisorAgent.process({ message: input.message, sessionId: input.sessionId, }); // Track routing metrics track('customer_request', { channel: input.channel || 'api', routedTo: result.metadata?.routedTo || 'unknown', }); return { message: result.message, metadata: result.metadata, }; } catch (error) { console.error('[multi-agent] Error:', error); return { error: 'An error occurred. Please try again.' }; } } ``` ## How It Works ``` Customer message arrives ↓ ┌─────────────────────┐ │ Supervisor │ gpt-4o-mini analyzes intent │ (routing only) │ └──────┬──────────────┘ ↓ What intent? ╱ │ ╲ Sales Support Billing ↓ ↓ ↓ gpt-4o gpt-4o gpt-4o + no + RAG + invoice tools + tools tools ↓ Response back to customer ``` ## Key Patterns ### Cheap Supervisor, Quality Specialists The supervisor only needs to classify intent — use `gpt-4o-mini` (fast, cheap). Specialists do the real work — use `gpt-4o` for quality responses. ### Built-in `agents` Config Runflow's `agents` config handles routing automatically. You define specialists inline and the supervisor routes based on its instructions: ```typescript theme={null} const supervisor = new Agent({ instructions: 'Route to sales, support, or billing...', model: openai('gpt-4o-mini'), agents: { sales: { name: 'Sales', instructions: '...', model: openai('gpt-4o') }, support: { name: 'Support', instructions: '...', model: openai('gpt-4o') }, }, }); ``` ### Shared Tools Across Specialists Some tools (like `create-ticket`) are used by multiple specialists. Define them once in `tools/` and assign to each agent that needs them. ### Conversation Continuity Memory is shared across the supervisor session. If a customer starts with a support question and then asks about billing, the context carries over — the billing agent knows what was discussed before. ## When to Use Multi-Agent | Scenario | Use | | ----------------------------------------------- | --------------- | | Single-purpose bot (FAQ, scheduling) | Single agent | | Multiple domains with different tools/knowledge | **Multi-agent** | | Complex routing with fallbacks | **Multi-agent** | | Different response styles per department | **Multi-agent** | ## Next Steps Supervisor pattern details Single-agent support example Sales workflow example Tips for effective agents # Sales Automation with Workflow Source: https://docs.runflow.ai/use-cases/sales-automation Automate lead qualification, deal creation, and personalized outreach using workflows A sales automation pipeline that qualifies leads with AI, creates contacts in HubSpot, and generates personalized outreach emails. This example shows how to combine workflows with agents and connectors for a fully automated sales process. ## Project Structure ``` sales-automation/ ├── main.ts ├── workflows/ │ └── lead-to-deal.ts ├── agents/ │ ├── qualifier.ts │ └── copywriter.ts ├── config/ │ └── settings.ts ├── .runflow/ │ └── rf.json ├── package.json └── tsconfig.json ``` ## Step 1: Configuration Define scoring thresholds and qualification criteria: ```typescript config/settings.ts theme={null} export const QUALIFICATION_THRESHOLD = 7; // Score >= 7 = qualified export const LEAD_SOURCES = ['website', 'referral', 'event', 'outbound', 'partner'] as const; export type LeadSource = (typeof LEAD_SOURCES)[number]; export const WORKFLOW_CONFIG = { qualifierModel: 'gpt-4o-mini', // Cheaper model for classification copywriterModel: 'gpt-4o', // Better model for content generation }; ``` ## Step 2: Specialized Agents Each agent handles one part of the pipeline. ### Lead Qualifier Uses a cheaper model — it just needs to score and classify: ```typescript agents/qualifier.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; import { WORKFLOW_CONFIG } from '../config/settings'; export const qualifierAgent = new Agent({ name: 'Lead Qualifier', instructions: `You are a lead qualification specialist. ## Task Analyze the lead data provided and assign a qualification score from 1 to 10. ## Scoring Criteria - 9-10: Enterprise buyer, clear budget, immediate timeline - 7-8: Strong fit, budget likely, near-term timeline - 5-6: Moderate fit, unclear budget or timeline - 3-4: Low fit, exploring options - 1-2: Not a fit, wrong persona or market ## Response Format Respond with valid JSON only: { "score": , "reasoning": "", "buyerPersona": "", "urgency": "" }`, model: openai(WORKFLOW_CONFIG.qualifierModel), modelConfig: { temperature: 0 }, }); ``` ### Sales Copywriter Uses a better model for quality content: ```typescript agents/copywriter.ts theme={null} import { Agent, openai } from '@runflow-ai/sdk'; import { WORKFLOW_CONFIG } from '../config/settings'; export const copywriterAgent = new Agent({ name: 'Sales Copywriter', instructions: `You are a sales email specialist. ## Task Write a personalized sales email based on the lead profile and qualification data. ## Rules - Keep it under 150 words - Reference the lead's specific company and interest - Include one clear call-to-action (schedule a demo, book a call) - Be consultative, not pushy - Do NOT use generic phrases like "I hope this email finds you well" ## Response Format Respond with valid JSON: { "subject": "", "body": "" }`, model: openai(WORKFLOW_CONFIG.copywriterModel), modelConfig: { temperature: 0.7 }, }); ``` ## Step 3: Workflow Definition The workflow orchestrates the full pipeline with conditional branching: ```typescript workflows/lead-to-deal.ts theme={null} import { createWorkflow } from '@runflow-ai/sdk'; import { z } from 'zod'; import { qualifierAgent } from '../agents/qualifier'; import { copywriterAgent } from '../agents/copywriter'; import { QUALIFICATION_THRESHOLD } from '../config/settings'; export const leadToDealWorkflow = createWorkflow({ id: 'lead-to-deal', inputSchema: z.object({ leadEmail: z.string().email(), leadName: z.string(), company: z.string(), role: z.string().optional(), source: z.string(), notes: z.string(), }), outputSchema: z.any(), }) // Step 1: Qualify the lead with AI .agent('qualify', qualifierAgent, { promptTemplate: `Analyze this lead: Name: {{input.leadName}} Company: {{input.company}} Role: {{input.role}} Source: {{input.source}} Notes: {{input.notes}} Provide score and analysis.`, }) // Step 2: Branch based on score .condition( 'check-score', (ctx) => { try { const analysis = JSON.parse(ctx.stepResults.get('qualify').text); return analysis.score >= QUALIFICATION_THRESHOLD; } catch { return false; } }, // Qualified lead path [ // Create contact in HubSpot { id: 'create-contact', type: 'connector', config: { connector: 'hubspot', resource: 'contacts', action: 'create', parameters: { email: '{{input.leadEmail}}', firstname: '{{input.leadName}}', company: '{{input.company}}', jobtitle: '{{input.role}}', lifecyclestage: 'lead', lead_source: '{{input.source}}', }, }, }, // Generate personalized email { id: 'write-email', type: 'agent', config: { agent: copywriterAgent, promptTemplate: `Write a personalized sales email: Lead: {{input.leadName}} ({{input.role}}) at {{input.company}} Source: {{input.source}} Qualification: {{qualify.text}} Notes: {{input.notes}}`, }, }, ], // Low score path — log and skip [ { id: 'log-skipped', type: 'function', config: { execute: async (input, ctx) => { return { status: 'skipped', reason: 'Below qualification threshold', lead: input.leadName, }; }, }, }, ] ) .build(); ``` ## Step 4: Main Entry Point Wire the workflow into `main.ts` with identification and metrics: ```typescript main.ts theme={null} import { identify, track } from '@runflow-ai/sdk/observability'; import { leadToDealWorkflow } from './workflows/lead-to-deal'; export async function main(input: any) { // Validate required fields if (!input?.leadEmail || !input?.leadName || !input?.company) { return { error: 'leadEmail, leadName, and company are required' }; } // Identify by lead email identify(input.leadEmail); try { const result = await leadToDealWorkflow.execute({ leadEmail: input.leadEmail, leadName: input.leadName, company: input.company, role: input.role || '', source: input.source || 'unknown', notes: input.notes || '', }); // Parse qualification result let score = 0; try { const analysis = JSON.parse(result.stepResults?.qualify?.text || '{}'); score = analysis.score || 0; } catch {} // Track sales metrics track('lead_processed', { source: input.source, score, qualified: score >= 7, company: input.company, }); return { message: score >= 7 ? `Lead ${input.leadName} qualified (score: ${score}). Contact created and email drafted.` : `Lead ${input.leadName} scored ${score} — below threshold. Skipped.`, result, }; } catch (error) { console.error('[sales-automation] Error:', error); return { error: 'Failed to process lead' }; } } ``` ## How It Works ``` Lead data comes in ↓ ┌──────────────────┐ │ Qualify (AI) │ gpt-4o-mini scores 1-10 └──────┬───────────┘ ↓ Score >= 7? ╱ ╲ Yes No ↓ ↓ Create Log & skip HubSpot contact ↓ Generate sales email (gpt-4o) ↓ Return result ``` ## Key Patterns ### Cheap Model for Classification, Good Model for Content Use `gpt-4o-mini` for tasks like scoring and classification — it's faster and cheaper. Save `gpt-4o` for content generation where quality matters. ### Structured JSON Responses Tell agents to respond with valid JSON and parse it in the workflow conditions. This makes branching reliable: ```typescript theme={null} // In the agent instructions "Respond with valid JSON: { \"score\": , ... }" // In the workflow condition .condition('check-score', (ctx) => { const analysis = JSON.parse(ctx.stepResults.get('qualify').text); return analysis.score >= 7; }) ``` ### Workflow vs Agent This example uses a **workflow** because it's a pipeline — data flows in, gets processed through steps, and comes out. There's no conversation. Use workflows when the process is linear, not conversational. ## Next Steps Learn more about workflows Integrate with HubSpot, Slack, etc. WhatsApp collections example Tips for effective agents # SDR Agent with Scheduled Follow-ups Source: https://docs.runflow.ai/use-cases/sdr-follow-up Build an SDR agent that qualifies leads via WhatsApp and automatically follows up using scheduled callbacks with conversation memory An SDR agent that qualifies leads via WhatsApp, schedules callbacks when the lead asks ("call me tomorrow at noon"), and automatically follows up on leads that stopped responding. The agent uses `identify()` + `Memory` to resume conversations exactly where they left off — no context lost. ## Project Structure ``` sdr-agent/ ├── main.ts ├── agent.ts ├── tools/ │ ├── qualify-lead.ts │ └── close-conversation.ts ├── .runflow/ │ └── rf.json ├── package.json └── tsconfig.json ``` ## How It Works Two scenarios, same agent: ``` SCENARIO 1: Lead asks to be called back ──────────────────────────────────────── Lead: "Me liga amanha meio dia" → Agent calls create_schedule (SDK captures conversation context automatically) → Tomorrow at noon, trigger fires with entityType/entityValue → main() calls identify() → memory loads → agent resumes conversation SCENARIO 2: Lead stops responding ──────────────────────────────────────── CRON trigger fires every 2 hours (business hours) → main() calls Memory.list() to find inactive sessions → Filters by lastMessage.role === 'assistant' (agent was the last to speak) → For each lead, calls agent.process() with entityType/entityValue → Memory loads → agent sends natural follow-up ``` ## Session Lifecycle The agent controls the session status through tools. The developer never calls `Memory.setStatus()` manually — the tools do it: ``` New conversation → status: null (in progress) Agent qualifies (BANT >= 7) → tool qualify-lead sets "qualified" Agent qualifies (BANT < 7) → tool qualify-lead sets "nurturing" Lead says "not interested" → tool close-conversation sets "closed" Lead asks for callback → create_schedule (status stays null) CRON checks inactive leads → Memory.list() skips "qualified" and "closed" ``` ## Step 1: The Agent ```typescript agent.ts theme={null} import { Agent, openai, createScheduleTools } from '@runflow-ai/sdk'; import { qualifyLeadTool } from './tools/qualify-lead'; import { closeConversationTool } from './tools/close-conversation'; const scheduleTools = createScheduleTools(); export const sdrAgent = new Agent({ name: 'SDR Agent', instructions: `You are an SDR qualifying leads via WhatsApp for ACME Corp. ## Qualification (BANT) - Budget: can they afford the solution? - Authority: are they the decision maker? - Need: do they have a real problem we solve? - Timeline: when do they need a solution? Ask one question at a time. Be natural, not robotic. ## Tools — when to use each - **qualify-lead**: Use when you have enough info to score the lead (at least 3 of 4 BANT criteria). This marks the conversation as qualified or nurturing. - **close-conversation**: Use when the lead explicitly says they are not interested, asks to stop, or is a wrong contact. This marks the conversation as closed. - **create_schedule**: Use when the lead asks to be called back later. ALWAYS set maxExecutions to 1 for one-time callbacks. ## Resuming conversations - When resuming a scheduled callback, greet naturally: "Oi [name]! Conforme combinamos..." - When following up on an inactive lead, be brief and casual. Reference the last topic discussed. - Don't repeat questions they already answered.`, model: openai('gpt-4o'), tools: { create_schedule: scheduleTools.create_schedule, qualifyLead: qualifyLeadTool, closeConversation: closeConversationTool, }, memory: { maxTurns: 30, summarizeAfter: 20, summarizePrompt: 'Summarize: lead name, BANT score so far, topics discussed, next steps, and any objections raised.', }, }); ``` ## Step 2: Tools That Control Session Status The tools set the session status automatically — the LLM decides when to call them based on the conversation. ### Qualify Lead ```typescript tools/qualify-lead.ts theme={null} import { createTool } from '@runflow-ai/sdk'; import { Memory } from '@runflow-ai/sdk'; import { track } from '@runflow-ai/sdk/observability'; import { z } from 'zod'; export const qualifyLeadTool = createTool({ id: 'qualify-lead', description: 'Record lead qualification score based on BANT criteria. Use when you have enough info to score (at least 3 of 4 criteria answered). This marks the session status.', inputSchema: z.object({ leadName: z.string(), budget: z.number().min(0).max(10), authority: z.number().min(0).max(10), need: z.number().min(0).max(10), timeline: z.number().min(0).max(10), notes: z.string().optional(), }), execute: async (params) => { const score = (params.budget + params.authority + params.need + params.timeline) / 4; const qualified = score >= 7; const status = qualified ? 'qualified' : 'nurturing'; // Set session status — this is how Memory.list() knows to skip this lead await Memory.setStatus(status); track('lead_qualified', { leadName: params.leadName, score, qualified, }); return { score: Math.round(score * 10) / 10, qualified, status, recommendation: qualified ? 'Lead qualificado. Agendar demo com AE.' : 'Lead morno. Continuar nurturing.', }; }, }); ``` ### Close Conversation ```typescript tools/close-conversation.ts theme={null} import { createTool } from '@runflow-ai/sdk'; import { Memory } from '@runflow-ai/sdk'; import { track } from '@runflow-ai/sdk/observability'; import { z } from 'zod'; export const closeConversationTool = createTool({ id: 'close-conversation', description: 'Close this conversation permanently. Use when: lead says they are not interested, asks to stop receiving messages, is a wrong contact, or the conversation is finished.', inputSchema: z.object({ reason: z.enum(['not_interested', 'wrong_contact', 'already_customer', 'completed', 'other']), notes: z.string().optional(), }), execute: async (params) => { await Memory.setStatus('closed'); track('conversation_closed', { reason: params.reason }); return { closed: true, reason: params.reason }; }, }); ``` ## Step 3: The Entry Point `main.ts` handles all entry points — regular messages, scheduled callbacks, and CRON follow-ups: ```typescript main.ts theme={null} import { identify, Memory } from '@runflow-ai/sdk'; import { sdrAgent } from './agent'; export async function main(input: any) { // Identify the lead — this is what loads the right memory const phone = input.entityValue || input.metadata?.phone; if (phone) { identify(phone); // Auto-detects type as 'phone' } // Check if this is a CRON trigger for inactive leads if (input.metadata?.isScheduled && input.message === 'Check inactive leads') { return handleInactiveLeads(); } // Regular conversation or scheduled callback — same flow return sdrAgent.process(input); } ``` ## Step 4: Scheduled Callback (Automatic) When a lead says "me liga amanha meio dia", the LLM calls `create_schedule`. Here's what happens behind the scenes: ``` 1. LLM calls create_schedule with { name, type: 'daily', time: '12:00', message, maxExecutions: 1 } 2. SDK automatically captures the current identify() state: { entityType: 'phone', entityValue: '+5511999999999', sessionId: '...' } 3. Sends to backend as executionContext (stored in trigger metadata) 4. Tomorrow at 12:00, trigger fires with: { message: "Retomar conversa de qualificacao", entityType: "phone", ← injected from executionContext entityValue: "+5511999999999", ← injected from executionContext metadata: { isScheduled: true, ... } } 5. main() calls identify('+5511999999999') 6. agent.process() → memoryId = 'phone:+5511999999999' → loads full history 7. Agent: "Oi Joao! Conforme combinamos, estou retornando..." 8. Schedule auto-deactivates (maxExecutions: 1 reached) ``` The developer writes **zero extra code** for this. The context capture and restoration is handled by the SDK and trigger engine. ## Step 5: Inactive Lead Follow-up For leads that stopped responding, create a CRON trigger in the portal that runs every 2 hours during business hours: ``` Name: "Check Inactive Leads" Type: SCHEDULER Schedule: CRON → 0 */2 9-18 * * (every 2h, 9am-6pm) Message: "Check inactive leads" ``` Then handle it in `main.ts`: ```typescript main.ts theme={null} // ... main() function from above ... async function handleInactiveLeads() { // Find leads inactive for 4+ hours that haven't been qualified or closed const inactive = await Memory.list({ lastInteractionBefore: new Date(Date.now() - 4 * 60 * 60 * 1000), // status: null means "in progress" — skips qualified, closed, nurturing }); let processed = 0; for (const session of inactive) { // Skip sessions that already have a status (qualified, closed, etc.) if (session.status) continue; // Only follow up if the agent was the last to speak (lead didn't respond) if (session.lastMessage?.role !== 'assistant') continue; // Skip if no entity info (can't identify the lead) if (!session.entityType || !session.entityValue) continue; // Pass context directly in the input — no identify() in the loop await sdrAgent.process({ message: 'This lead has not responded in a few hours. Send a brief, natural follow-up referencing the last topic discussed.', entityType: session.entityType, entityValue: session.entityValue, channel: 'whatsapp', }); processed++; } return { processed, message: `Followed up with ${processed} inactive leads` }; } ``` **No `identify()` in the loop.** Since `identify()` sets a global singleton, calling it repeatedly in a loop would cause race conditions. Instead, pass `entityType`/`entityValue` directly in the `agent.process()` input — the agent resolves the memory key from the input fields. ## Key Concepts ### Tools Control Status, Not the Developer The developer never calls `Memory.setStatus()` directly. The tools do it: | Tool | When the LLM calls it | Status set | | -------------------- | ----------------------------------- | -------------------- | | `qualify-lead` | Lead scored BANT >= 7 | `qualified` | | `qualify-lead` | Lead scored BANT \< 7 | `nurturing` | | `close-conversation` | Lead not interested / wrong contact | `closed` | | (no tool) | Lead still in conversation | `null` (in progress) | `Memory.list()` then filters by status to find only active leads. ### Memory Drives Everything The entire follow-up system relies on `identify()` setting the right memory key. No external CRM needed for basic context — the conversation history IS the context. ```typescript theme={null} import { identify } from '@runflow-ai/sdk'; // Auto-detects phone → memoryId = 'phone:+5511999999999' identify('+5511999999999'); // Whether it's a WhatsApp message, a scheduled callback, or a CRON follow-up // — if identify() is called with the same phone, the agent has full history. ``` ### Same Agent, Multiple Entry Points The agent doesn't need to know if it's handling a live conversation, a scheduled callback, or a batch follow-up. The `main.ts` normalizes the input and the agent processes it the same way. | Entry Point | How context flows | | ------------------ | ------------------------------------------------------------------------ | | WhatsApp message | `identify(input.metadata.phone)` — sets global state | | Scheduled callback | `identify(input.entityValue)` — injected from trigger's executionContext | | CRON follow-up | `entityType`/`entityValue` passed directly in `agent.process()` input | ## Next Steps Schedule tools reference and security guide How memory and identify() work Track lead qualification metrics Supervisor pattern for complex routing # WhatsApp Agent from Scratch Source: https://docs.runflow.ai/use-cases/whatsapp-agent-from-scratch End-to-end walkthrough: create the connector and credential, handle Meta's webhook handshake, and go from zero to a live WhatsApp agent This guide walks the **whole path** to a working WhatsApp agent — nothing assumed, nothing skipped: 1. Create the **WhatsApp connector** with its credential and resources (prebuilt or from scratch) 2. Write the **complete agent** with the [Channels module](/core-concepts/channels) 3. Grab the agent URL and enable **raw mode** (`&raw=true`) 4. Pass **Meta's webhook verification handshake** 5. Send a message and watch the agent reply The flow you are building: ``` WhatsApp user → Meta Cloud API → your agent URL (?raw=true) │ parse (channels) ▼ your turn (Agent) │ reply (channels) ▼ connector send-message → Meta Cloud API → WhatsApp user ``` ## Prerequisites * A **Meta developer app** ([developers.facebook.com](https://developers.facebook.com)) with the **WhatsApp** product added. The API Setup page gives you a test phone number for free. * The **Phone Number ID** — shown on *WhatsApp → API Setup* (it is not the phone number itself). * An **access token**. The temporary token from API Setup works for testing (expires in 24h); for anything real, create a **System User token** in Meta Business Settings with the `whatsapp_business_messaging` permission — it doesn't expire. * A Runflow agent project (`rf create`) with `@runflow-ai/sdk` **v1.5.0+**. The channel provider sends every outbound message through a **connector**, so the Meta token lives server-side — your agent code never sees it. Two ways to get the connector: In the portal, open **Connectors** and pick **WhatsApp Business Cloud** from the prebuilt catalog. The template comes preconfigured with the Graph API base URL (`https://graph.facebook.com/v18.0`) and the auth shape — you only fill in the **Access Token** credential, which is sent as `Authorization: Bearer ` on every call. The prebuilt connector already ships the two resources the channel provider uses: | Resource slug | What it does | | --------------- | -------------------------------------------------------------------------------------------------------------------- | | `send-message` | Raw passthrough to `POST /{phone_number_id}/messages` — one resource for every message type, mark-as-read and typing | | `get-media-url` | `GET /{media_id}` — resolves an inbound media id to its download URL | If your WhatsApp connector was created before the `send-message` **raw passthrough** existed, add it yourself with the exact definition from the "Custom connector" tab — the older per-type resources (`send-text-message`, etc.) are not what the provider uses. If you prefer to own the connector (or need a different Graph API version), create one from zero: **Connectors → Create Connector**, type **REST API**, base URL: ``` https://graph.facebook.com/v18.0 ``` Attach a **credential** holding your Meta access token, sent as a header: `Authorization: Bearer {token}`. Then create the two resources the provider calls: | Name | Slug | Method | Path | | ------------------------------ | --------------- | ------ | ----------------------------- | | Send Message (raw passthrough) | `send-message` | `POST` | `/{phone_number_id}/messages` | | Get Media URL | `get-media-url` | `GET` | `/{media_id}` | Two rules that make or break this path: * **`send-message` must forward the request `body` untouched.** The channel provider builds the complete Graph API payload (`messaging_product`, `to`, `type`, the type-specific object — and also mark-as-read/typing payloads). Don't restrict the body schema to specific fields; declare it as a free-form object. * **Keep the slugs above**, or pass yours to the provider: `meta({ sendResource: 'my-slug', mediaResource: 'my-other-slug' })`. Whichever path you took, note the **instance slug** of your connector (e.g. `whatsapp-acme`) — you'll pass it to `meta()` in the next step. Smoke-test the credential before writing any code: on the connector's detail page, open the `send-message` resource in the request builder and send a minimal body (`{ "messaging_product": "whatsapp", "to": "", "type": "text", "text": { "body": "ping" } }`) with your `phone_number_id` as the path param. If Graph returns `401`, fix the token now — not after deploying. This is the **entire agent** — four files, all shown in full. Copy them as-is, change the connector slug, phone number id and verify token, and you have a working WhatsApp agent. ``` whatsapp-agent/ ├── main.ts # entry point: handshake + channel handler ├── meta-handshake.ts # Meta webhook verification └── agent/ ├── agent.ts # the Agent definition └── runner.ts # the turn: events in → replies out ``` The entry point does two jobs: answer Meta's verification handshake and delegate everything else to the channel handler. ```typescript main.ts theme={null} import { createChannelHandler, meta } from '@runflow-ai/sdk/channels'; import { metaHandshake } from './meta-handshake'; import { runTurn } from './agent/runner'; const handler = createChannelHandler({ provider: meta({ connector: 'whatsapp-acme', // your connector instance slug phoneNumberId: '123456789012345', // from WhatsApp → API Setup }), run: runTurn, }); export async function main(input: any) { // Meta verifies the webhook with a GET before delivering any events. // In raw mode the returned string is echoed back as plain text — exactly // what Meta's validator expects. const challenge = metaHandshake(input); if (challenge !== null) return challenge; return handler(input); } ``` The handshake module. Three details matter here: a request without `hub.*` params is a **normal event, not an error**; the **verify token must be checked** (otherwise anyone who finds your URL can pass verification); and query values arrive as **arrays** (one entry per repeated param), so normalize both shapes: ```typescript meta-handshake.ts theme={null} // Meta webhook verification handshake: // GET ?hub.mode=subscribe&hub.verify_token=&hub.challenge= // Meta expects the bare challenge string back with HTTP 200. /** Any string you choose — you'll type this SAME value in Meta's webhook config. */ const VERIFY_TOKEN = 'runflow-whatsapp-2026'; function queryParam( query: Record, name: string, ): string | undefined { const value = query[name]; if (Array.isArray(value)) { return typeof value[0] === 'string' ? value[0] : undefined; } return typeof value === 'string' ? value : undefined; } /** * Returns the challenge to echo back when the request is a valid handshake, * or `null` when it isn't a handshake at all (a normal webhook event — * let the channel handler process it). * * Throws on a handshake with a wrong verify token, so verification fails * loudly instead of silently accepting anyone. */ export function metaHandshake(input: { request?: { query?: Record }; }): string | null { const query = input?.request?.query; if (!query) return null; if (queryParam(query, 'hub.mode') !== 'subscribe') return null; if (queryParam(query, 'hub.verify_token') !== VERIFY_TOKEN) { throw new Error( 'Meta webhook verification failed: hub.verify_token does not match VERIFY_TOKEN', ); } const challenge = queryParam(query, 'hub.challenge'); if (!challenge) { throw new Error('Meta webhook verification failed: hub.challenge missing'); } return challenge; } ``` The Agent — instructions, model and conversation memory: ```typescript agent/agent.ts theme={null} import { Agent } from '@runflow-ai/sdk'; import { openai } from '@runflow-ai/sdk/models'; export const agent = new Agent({ name: 'whatsapp-assistant', instructions: `You are Acme's WhatsApp assistant. Answer in the user's language. Be brief — this is chat, not email. If you don't know something, say so instead of guessing.`, model: openai('gpt-4o'), memory: { maxTurns: 20 }, }); ``` The turn. The platform may deliver a burst of quick messages as **one turn with several events** — flatten them into a single message, call the agent once, reply once: ```typescript agent/runner.ts theme={null} import { reply, type RunTurn } from '@runflow-ai/sdk/channels'; import { agent } from './agent'; export const runTurn: RunTurn = async ({ ctx, events }) => { const parts: string[] = []; for (const event of events) { switch (event.type) { case 'text': parts.push(event.text); break; case 'button': // The user tapped a quick-reply button — the label is the intent parts.push(event.text || event.payload); break; case 'audio': parts.push( '[The user sent a voice note. Tell them you cannot listen to audio yet and ask them to type it.]', ); break; case 'image': case 'video': case 'document': parts.push( event.caption ? `[${event.type} received] ${event.caption}` : `[${event.type} received]`, ); break; case 'location': parts.push(`[location] lat ${event.latitude}, lng ${event.longitude}`); break; default: // 'contacts', 'flow-reply', 'unknown' — inspect event.raw when you need them break; } } // Nothing actionable in this burst (e.g. only unsupported events): stay silent. if (parts.length === 0) return []; const result = await agent.process({ message: parts.join('\n'), userId: ctx.userId, // the sender's WhatsApp number, already normalized sessionId: ctx.userId, // one conversation thread per user }); return [reply.text(result.message)]; }; ``` Want real audio support? Resolve the media with `fetchMedia` and transcribe it with the SDK — see [Channels → Media](/core-concepts/channels#media-audio-images-files). On Meta the download URL is Bearer-gated, so plan for a `mediaResource` that returns the bytes. If `tsc` fails with `TS2307: Cannot find module '@runflow-ai/sdk/channels' ... under your current 'moduleResolution' setting`, set `"moduleResolution": "bundler"` in your `tsconfig.json`. Older `rf create` scaffolds ship `"moduleResolution": "node"`, which cannot resolve the SDK's subpath exports. Deploy it: ```bash theme={null} rf agents deploy ``` `rf agents deploy` always targets **staging** — you'll validate there first and [promote to production](/core-concepts/environments) later. Open your agent in the portal (**Agents → your agent**) and copy the **endpoint URL** from the info panel. It looks like: ``` https://executor.staging.runflow.ai/agent/?token=agt_... ``` (Staging and production have different hosts — copy the URL for the environment you deployed to.) Now append `&raw=true`: ``` https://executor.staging.runflow.ai/agent/?token=agt_...&raw=true ``` **Why raw mode matters here:** by default the endpoint wraps your agent's return value in a JSON envelope (`{ message, metadata, ... }`). Meta's webhook validator expects the **bare challenge string** back — `12345`, not `{"message":"12345"}`. With `raw=true`: * The response body is **exactly what `main` returned** — a string goes out as `text/plain`, unquoted * Execution metadata moves to response headers (`X-Runflow-Execution-Id`, `X-Runflow-Duration-Ms`, …) Without `&raw=true` the handshake **will fail** — this is the most common mistake in this whole flow. The `token` in the URL is your agent's access token. The full URL is a secret — anyone holding it can execute your agent. You can verify the handshake yourself before involving Meta: ```bash theme={null} curl "https://executor.staging.runflow.ai/agent/?token=agt_...&raw=true&hub.mode=subscribe&hub.verify_token=runflow-whatsapp-2026&hub.challenge=12345" # → 12345 ``` If that prints `12345` (and nothing else), you're ready. Try it with a wrong `hub.verify_token` too — it must **not** return the challenge. In your Meta app: **WhatsApp → Configuration → Webhook → Edit**: * **Callback URL** — your agent URL **including** `&raw=true` * **Verify token** — the exact `VERIFY_TOKEN` value from `meta-handshake.ts` Click **Verify and save**. Meta sends the `GET` handshake; your agent echoes the challenge; Meta accepts. Then, still on the Configuration page, **subscribe to the `messages` webhook field** — without this subscription Meta verifies fine but never delivers a single message (the second most common mistake). The verification handshake runs a real execution — you'll see it in [Observability](/core-concepts/observability), which is also a nice first confirmation that the wiring works. Message the WhatsApp number (on a test number, add your phone to the recipient allowlist on *API Setup* first). You should get the agent's reply within a few seconds. Behind the scenes: Meta POSTs the webhook → the `meta()` provider parses it into a `text` event with `userId` = the sender's number (Brazilian numbers normalized automatically) → your `runTurn` runs → `reply.text(...)` is rendered to the Graph API format and POSTed through your connector's `send-message` resource. Check **Observability → Executions** to watch the full trace, including the outbound connector call. ## Troubleshooting | Symptom | Cause | | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | Meta says "callback URL couldn't be validated" | Missing `&raw=true`; verify token mismatch; or the agent isn't deployed to the environment of the URL you used. Test with the `curl` above. | | Handshake OK, but messages never arrive | You didn't subscribe to the `messages` webhook field, or (test numbers) the sender isn't in the recipient allowlist. | | Execution runs but no reply is sent | Wrong `connector` slug or `phoneNumberId` — the provider fails loud with a legible error in the execution trace. | | Connector call returns `401` | Token expired (temporary tokens last 24h) or missing `whatsapp_business_messaging` permission. Swap in a System User token. | | Connector call returns `400` from Graph | Your custom `send-message` resource is rewriting or restricting the body — it must forward the provider's payload verbatim. | | Replies arrive as `[object Object]` or JSON | You bypassed the channel handler and returned an object from a hand-rolled send — return `OutboundMessage[]` from `runTurn` and let the provider render. | ## Going to production Using the Conversation Hub addon? After the first connection, new numbers registered on your WABA are **imported automatically** by the periodic sync — no need to repeat this setup per number. Set a default agent for the WABA so imported numbers route immediately: see [Channels → Automatic number sync](/core-concepts/channels#automatic-number-sync-conversation-hub). * **Promote the agent** to production and update Meta's callback URL to the production endpoint (`executor.runflow.ai`) — or use a separate Meta app per environment. * **This direct URL is synchronous**: Meta waits while your turn runs, and retries on timeout — there is no dedup or burst batching on this path. It's perfect to get live and for moderate traffic; for high-volume production, front the agent with an **HTTP trigger** (debounce/coalescing delivers the burst as `input.events[]`) — the channel handler works unchanged on both paths. * **Media**: inbound audio/images arrive as media events; resolve them with `fetchMedia` and the `get-media-url` resource — see [Channels → Media](/core-concepts/channels#media-audio-images-files). ## Next Steps Buttons, media, typing indicators, multi-channel routing How connector instances, credentials and resources work Persist the conversation across messages Trace every execution, including connector calls