Skip to Content

Retool

Build internal AI tools and dashboards with Retool that submit prompts to Convoy and display results — perfect for operations teams, content managers, and support staff who need AI capabilities without writing code.

Testing tip — Build and verify this integration end-to-end with convoy-mock first. It’s a free, synthetic model that returns a callback in ~60 seconds and is never billed. Once your wiring works, swap "model": "convoy-mock" for a production model like claude-3-haiku.

Retool connects to Convoy via REST API queries. You can build forms to submit prompts, tables to track results, and dashboards to monitor usage — all with drag-and-drop components.


What You’ll Build

This guide walks through building a complete internal AI tool in Retool:

  1. Prompt Submission Form — Users enter prompts, select models, and submit to Convoy
  2. Results Dashboard — Track cargo status and view completed results
  3. Batch Processing Tool — Upload CSVs and process rows through Convoy
  4. Webhook Receiver — Automatically update results when Convoy delivers callbacks

Prerequisites

  • Retool account (Cloud or Self-hosted)
  • Convoy API key (convoy_sk_...)
  • A webhook endpoint for receiving callbacks (Retool Workflows or external server)

Step 1: Configure the Convoy API Resource

First, set up Convoy as a reusable REST API resource in Retool:

  1. Go to ResourcesCreate NewREST API
  2. Configure:
FieldValue
NameConvoy API
Base URLhttps://api.cnvy.ai
HeadersX-API-Key: {{ CONVOY_API_KEY }}
Content-Type: application/json
  1. Under Environment Variables, add:

    • CONVOY_API_KEY = convoy_sk_your_key_here
  2. Click Save

Store your API key as a Retool Environment Variable or Secret — never hardcode it in queries. Go to SettingsEnvironment Variables to configure.


Step 2: Create API Queries

Query: Submit Prompt

Create a new query named submitPrompt:

Type: REST API (Convoy API resource)

Method: POST Path: /cargo/load Body (JSON): { "params": { "model": {{ modelSelect.value }}, "max_tokens": {{ maxTokensInput.value || 1024 }}, "messages": [ { "role": "user", "content": {{ promptInput.value }} } ], "system": {{ systemPromptInput.value || undefined }} }, "callback_url": {{ callbackUrlInput.value || undefined }} }

Transform response:

return { cargo_id: data.cargo_id, status: data.status, submitted_at: new Date().toISOString() }

Query: Track Cargo

Create a query named trackCargo:

Method: GET Path: /cargo/{{ cargoIdInput.value }}/tracking

Transform response:

return { cargo_id: data.cargo_id, status: data.status, response_text: data.response?.content?.[0]?.text || null, model: data.response?.model || null, input_tokens: data.response?.usage?.input_tokens || 0, output_tokens: data.response?.usage?.output_tokens || 0, error: data.error || null }

Query: List Models

Create a query named listModels:

Method: GET Path: /models

Set this to Run on page load so the model dropdown is always populated.


Step 3: Build the Submission Form

Create a new Retool app and add these components:

Layout

┌─────────────────────────────────────────────────┐ │ 🚛 Convoy AI Tool │ ├─────────────────────────────────────────────────┤ │ │ │ Model: [claude-3-haiku ▼] │ │ │ │ System Prompt (optional): │ │ ┌─────────────────────────────────────────┐ │ │ │ You are a helpful assistant... │ │ │ └─────────────────────────────────────────┘ │ │ │ │ Prompt: │ │ ┌─────────────────────────────────────────┐ │ │ │ │ │ │ │ Enter your prompt here... │ │ │ │ │ │ │ └─────────────────────────────────────────┘ │ │ │ │ Max Tokens: [1024 ] Temperature: [0.7 ] │ │ │ │ [Submit to Convoy] │ │ │ │ ───────────────────────────────────────────── │ │ │ │ Status: ● PROCESSING │ │ Cargo ID: cargo_abc123... │ │ │ │ Result: │ │ ┌─────────────────────────────────────────┐ │ │ │ The AI response will appear here... │ │ │ └─────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────┘

Component Configuration

Model Select (modelSelect):

  • Type: Select
  • Data source: {{ listModels.data.map(m => ({ label: m.id, value: m.id })) }}
  • Default: claude-3-haiku

System Prompt (systemPromptInput):

  • Type: Text Area
  • Placeholder: “Optional system prompt…”
  • Default: empty

Prompt Input (promptInput):

  • Type: Text Area
  • Placeholder: “Enter your prompt…”
  • Required: true

Max Tokens (maxTokensInput):

  • Type: Number Input
  • Default: 1024
  • Min: 1, Max: 4096

Submit Button:

  • Label: “Submit to Convoy”
  • Event handler: Run submitPrompt query
  • Loading state: {{ submitPrompt.isFetching }}

Status Display (statusText):

  • Type: Text
  • Value: {{ trackCargo.data?.status || 'Ready' }}
  • Color: {{ trackCargo.data?.status === 'COMPLETED' ? 'green' : trackCargo.data?.status === 'FAILED' ? 'red' : 'orange' }}

Result Display (resultText):

  • Type: Text Area (read-only)
  • Value: {{ trackCargo.data?.response_text || '' }}

Step 4: Auto-Polling for Results

Add a JavaScript query named pollForResult that automatically checks status:

// pollForResult — runs after submitPrompt succeeds const cargoId = submitPrompt.data.cargo_id; let attempts = 0; const maxAttempts = 360; // 1 hour at 10s intervals const interval = 10000; // 10 seconds async function poll() { attempts++; // Update the cargo ID input for the tracking query cargoIdInput.setValue(cargoId); // Run the tracking query await trackCargo.trigger(); const status = trackCargo.data?.status; if (status === 'COMPLETED' || status === 'FAILED') { // Done — show notification if (status === 'COMPLETED') { utils.showNotification({ title: 'Result Ready', description: `Cargo ${cargoId} completed successfully.`, notificationType: 'success', }); } else { utils.showNotification({ title: 'Request Failed', description: trackCargo.data?.error || 'Unknown error', notificationType: 'error', }); } return; } if (attempts >= maxAttempts) { utils.showNotification({ title: 'Polling Timeout', description: 'Result not ready after 1 hour. Check the dashboard.', notificationType: 'warning', }); return; } // Continue polling await new Promise(resolve => setTimeout(resolve, interval)); return poll(); } return poll();

Set the Submit Button event handler to:

  1. Run submitPrompt
  2. On success → Run pollForResult

Convoy processes requests in cost-efficient batches. Results arrive within minutes to hours, depending on queue volume and batch size (up to 24-hour SLA). The polling query handles this automatically.


Step 5: Results Dashboard

Create a second page or tab for viewing all submitted requests.

Results Table

Add a Table component connected to a query that fetches from your results store (e.g., a Retool Database, PostgreSQL, or the Retool state):

Option A: Retool Database

Create a Retool Database table convoy_results with columns:

  • cargo_id (text, primary key)
  • prompt (text)
  • model (text)
  • status (text)
  • result_text (text)
  • submitted_at (timestamp)
  • completed_at (timestamp)
  • input_tokens (integer)
  • output_tokens (integer)

Query to insert on submit:

INSERT INTO convoy_results (cargo_id, prompt, model, status, submitted_at) VALUES ( {{ submitPrompt.data.cargo_id }}, {{ promptInput.value }}, {{ modelSelect.value }}, 'PROCESSING', NOW() )

Query to update on completion:

UPDATE convoy_results SET status = {{ trackCargo.data.status }}, result_text = {{ trackCargo.data.response_text }}, input_tokens = {{ trackCargo.data.input_tokens }}, output_tokens = {{ trackCargo.data.output_tokens }}, completed_at = NOW() WHERE cargo_id = {{ trackCargo.data.cargo_id }}

Option B: Retool State (No Database)

Use a Retool State variable to track submissions in-memory:

// On submit success, add to state const current = submissionsState.value || []; submissionsState.setValue([ ...current, { cargo_id: submitPrompt.data.cargo_id, prompt: promptInput.value, model: modelSelect.value, status: 'PROCESSING', submitted_at: new Date().toISOString(), } ]);

Step 6: Batch Processing Tool

Build a CSV upload tool that processes multiple prompts:

CSV Upload Component

Add a File Upload component (csvUpload):

  • Accepted file types: .csv
  • Parse as: Text

Batch Submit Query

Create a JavaScript query batchSubmit:

// Parse CSV const csvText = csvUpload.value[0]?.data; if (!csvText) { utils.showNotification({ title: 'Error', description: 'No file uploaded', notificationType: 'error' }); return; } const lines = csvText.split('\n').filter(l => l.trim()); const headers = lines[0].split(',').map(h => h.trim().toLowerCase()); const promptCol = headers.indexOf('prompt'); const modelCol = headers.indexOf('model'); const systemCol = headers.indexOf('system'); if (promptCol === -1) { utils.showNotification({ title: 'Error', description: 'CSV must have a "prompt" column', notificationType: 'error' }); return; } const results = []; const rows = lines.slice(1); for (let i = 0; i < rows.length; i++) { const cols = rows[i].split(',').map(c => c.trim()); const prompt = cols[promptCol]; if (!prompt) continue; const model = modelCol >= 0 ? cols[modelCol] : 'claude-3-haiku'; const system = systemCol >= 0 ? cols[systemCol] : undefined; try { const response = await fetch('https://api.cnvy.ai/cargo/load', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': retoolContext.envVars.CONVOY_API_KEY, }, body: JSON.stringify({ params: { model: model || 'claude-3-haiku', max_tokens: 1024, messages: [{ role: 'user', content: prompt }], ...(system && { system }), }, metadata: { batch_index: String(i), source: 'retool-csv' }, }), }); const data = await response.json(); results.push({ row: i + 1, prompt: prompt.substring(0, 50) + '...', cargo_id: data.cargo_id, status: 'Submitted', }); } catch (err) { results.push({ row: i + 1, prompt: prompt.substring(0, 50) + '...', cargo_id: null, status: `Error: ${err.message}`, }); } // Rate limit: 1 request per second if (i < rows.length - 1) { await new Promise(resolve => setTimeout(resolve, 1000)); } } return results;

Batch Results Table

Add a Table component showing batch progress:

  • Data: {{ batchSubmit.data }}
  • Columns: Row, Prompt (truncated), Cargo ID, Status

Progress Bar

Add a Progress Bar component:

  • Value: {{ Math.round((batchSubmit.data?.length / (csvUpload.value[0]?.data?.split('\n').length - 1)) * 100) || 0 }}

Step 7: Webhook Receiver (Retool Workflows)

Use Retool Workflows to receive Convoy callbacks and update your results automatically:

Create a Workflow

  1. Go to WorkflowsCreate New
  2. Add a Webhook Trigger:
    • Copy the webhook URL (e.g., https://your-org.retool.com/workflows/abc123/startTrigger)
  3. Add a Code Block to process the callback:
// Extract callback payload const payload = webhookTrigger.data.body; const result = { cargo_id: payload.cargo_id, status: payload.status, success: payload.success, result_text: payload.success ? payload.response.content[0].text : payload.error, model: payload.response?.model, input_tokens: payload.response?.usage?.input_tokens || 0, output_tokens: payload.response?.usage?.output_tokens || 0, completed_at: new Date().toISOString(), }; return result;
  1. Add a Database Query block to update your results table:
UPDATE convoy_results SET status = {{ codeBlock.data.status }}, result_text = {{ codeBlock.data.result_text }}, input_tokens = {{ codeBlock.data.input_tokens }}, output_tokens = {{ codeBlock.data.output_tokens }}, completed_at = {{ codeBlock.data.completed_at }} WHERE cargo_id = {{ codeBlock.data.cargo_id }}
  1. Deploy the workflow

Use the Workflow URL as Callback

Update your submit query to include the workflow webhook URL:

{ "params": { ... }, "callback_url": "https://your-org.retool.com/workflows/abc123/startTrigger" }

With Retool Workflows handling callbacks, your dashboard updates automatically — no polling needed. Users see results appear in real-time as Convoy delivers them.


Step 8: Usage Analytics Dashboard

Add analytics components to track AI usage:

Token Usage Chart

Create a query tokenUsage:

SELECT DATE(submitted_at) as date, SUM(input_tokens) as total_input_tokens, SUM(output_tokens) as total_output_tokens, COUNT(*) as request_count FROM convoy_results WHERE submitted_at >= NOW() - INTERVAL '30 days' GROUP BY DATE(submitted_at) ORDER BY date

Add a Chart component:

  • Type: Bar chart
  • X-axis: {{ tokenUsage.data.date }}
  • Y-axis: {{ tokenUsage.data.total_output_tokens }}
  • Label: “Output Tokens by Day”

Model Usage Breakdown

SELECT model, COUNT(*) as requests, SUM(input_tokens + output_tokens) as total_tokens FROM convoy_results WHERE submitted_at >= NOW() - INTERVAL '7 days' GROUP BY model ORDER BY requests DESC

Status Summary

Add Statistic components:

  • Total Requests: {{ results.data.length }}
  • Completed: {{ results.data.filter(r => r.status === 'COMPLETED').length }}
  • Failed: {{ results.data.filter(r => r.status === 'FAILED').length }}
  • Processing: {{ results.data.filter(r => r.status === 'PROCESSING').length }}

Advanced Patterns

Template Library

Create a dropdown of pre-built prompt templates:

// templateSelect options const templates = [ { label: '📝 Summarize Text', value: 'summarize', system: 'You are a concise summarizer.', prompt: 'Summarize the following text in 3 bullet points:\n\n{input}', }, { label: '🌐 Translate', value: 'translate', system: 'You are a professional translator.', prompt: 'Translate the following to {language}:\n\n{input}', }, { label: '✍️ Rewrite', value: 'rewrite', system: 'You are an editor.', prompt: 'Rewrite the following in a {tone} tone:\n\n{input}', }, { label: '📊 Extract Data', value: 'extract', system: 'You extract structured data from text. Return JSON.', prompt: 'Extract the following fields from this text: {fields}\n\nText:\n{input}', }, ];

Multi-User Access Control

Use Retool’s built-in permissions:

  • Viewers — Can see results but not submit
  • Editors — Can submit prompts and view results
  • Admins — Can manage API keys and view usage analytics

Approval Workflow

For sensitive prompts, add an approval step:

// On submit, check if approval is needed const needsApproval = modelSelect.value.includes('sonnet') || maxTokensInput.value > 2048; if (needsApproval) { // Insert into approval queue instead of submitting directly await insertApprovalRequest.trigger(); utils.showNotification({ title: 'Approval Required', description: 'Your request has been sent for approval.', notificationType: 'info', }); } else { await submitPrompt.trigger(); }

Example: Customer Support Tool

A complete example for a support team using Convoy to draft responses:

Query: generateSupportReply

const ticket = ticketsTable.selectedRow; const response = await fetch('https://api.cnvy.ai/cargo/load', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': retoolContext.envVars.CONVOY_API_KEY, }, body: JSON.stringify({ params: { model: 'claude-3-sonnet', max_tokens: 1024, messages: [ { role: 'user', content: ticket.customer_message } ], system: `You are a customer support agent for ${companyName}. Draft a helpful, empathetic reply to the customer's message. Tone: Professional but friendly. Include: Acknowledgment of their issue, solution or next steps, closing.`, temperature: 0.4, }, callback_url: retoolContext.envVars.WORKFLOW_WEBHOOK_URL, metadata: { ticket_id: ticket.id, customer_email: ticket.email, source: 'retool-support-tool', }, }), }); return response.json();

CSV Format Reference

For the batch upload tool, use this CSV format:

prompt,model,system "Write a product description for wireless headphones",claude-3-haiku,"You are a marketing copywriter" "Summarize this article: [article text]",claude-3-sonnet, "Translate to Spanish: Hello, how are you?",claude-3-haiku,"You are a professional translator"
ColumnRequiredDescription
prompt✅ YesThe user message to send
modelNoModel ID (default: claude-3-haiku)
systemNoSystem prompt for this request

Troubleshooting

Query returns 401 Unauthorized

  • Check that CONVOY_API_KEY is set in Retool Environment Variables
  • Verify the key starts with convoy_sk_
  • Ensure the header is X-API-Key (case-sensitive)

Polling never completes

  • Convoy batch processing can take minutes to hours depending on queue volume
  • Increase the maxAttempts in the polling query
  • Consider using Retool Workflows with webhooks instead of polling

CSV batch fails partway through

  • Check the rate limit — Convoy allows 60 requests/minute
  • The 1-second delay between requests should prevent this
  • Look at the results table to see which rows succeeded

Workflow webhook not receiving callbacks

  • Verify the workflow is deployed (not just saved)
  • Check the webhook URL is correct in your submit query
  • Test the webhook URL with a manual curl request
  • Ensure the workflow trigger is set to Webhook type

Results not appearing in dashboard

  • Check that the database update query runs after the workflow processes the callback
  • Verify the cargo_id matches between submit and callback
  • Look at the workflow run history for errors

Security Best Practices

  1. API Key Storage — Use Retool Environment Variables, never hardcode
  2. Access Control — Restrict app access to authorized team members
  3. Audit Logging — Enable Retool’s audit log to track who submits what
  4. Rate Limiting — Add client-side limits to prevent accidental overuse
  5. Input Validation — Validate prompt length and content before submission

Next Steps

Last updated on