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:
- Prompt Submission Form — Users enter prompts, select models, and submit to Convoy
- Results Dashboard — Track cargo status and view completed results
- Batch Processing Tool — Upload CSVs and process rows through Convoy
- 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:
- Go to Resources → Create New → REST API
- Configure:
| Field | Value |
|---|---|
| Name | Convoy API |
| Base URL | https://api.cnvy.ai |
| Headers | X-API-Key: {{ CONVOY_API_KEY }} |
Content-Type: application/json |
-
Under Environment Variables, add:
CONVOY_API_KEY=convoy_sk_your_key_here
-
Click Save
Store your API key as a Retool Environment Variable or Secret — never hardcode it in queries. Go to Settings → Environment 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 }}/trackingTransform 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: /modelsSet 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
submitPromptquery - 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:
- Run
submitPrompt - 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
- Go to Workflows → Create New
- Add a Webhook Trigger:
- Copy the webhook URL (e.g.,
https://your-org.retool.com/workflows/abc123/startTrigger)
- Copy the webhook URL (e.g.,
- 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;- 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 }}- 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 dateAdd 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 DESCStatus 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"| Column | Required | Description |
|---|---|---|
prompt | ✅ Yes | The user message to send |
model | No | Model ID (default: claude-3-haiku) |
system | No | System prompt for this request |
Troubleshooting
Query returns 401 Unauthorized
- Check that
CONVOY_API_KEYis 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
maxAttemptsin 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_idmatches between submit and callback - Look at the workflow run history for errors
Security Best Practices
- API Key Storage — Use Retool Environment Variables, never hardcode
- Access Control — Restrict app access to authorized team members
- Audit Logging — Enable Retool’s audit log to track who submits what
- Rate Limiting — Add client-side limits to prevent accidental overuse
- Input Validation — Validate prompt length and content before submission
Next Steps
- Webhooks guide — Understand callback payloads in detail
- Python guide — Build custom backend logic for Retool to call
- Node.js guide — Create a middleware API between Retool and Convoy