Building Webhook-Enabled LLM Workflows in JavaScript with Codehooks.io
Most AI projects don't need a fleet of orchestration tools to run a few prompt chains. The real work is state management, retries, scheduling, and simple persistence—the operational glue between LLM API calls.
This post shows you how to build a production-ready text summarization workflow using Codehooks.io, the Workflow API, and OpenAI. You'll learn:
- OpenAI API integration patterns: Error handling, retries, rate limiting, and cost optimization
- Workflow state management: Building reliable multi-step processes with caching and persistence
- Webhook triggers: Event-driven workflows that respond to GitHub issues (and other external services)
- Programmatic access: How to trigger and manage workflows via REST API, CLI, and webhooks
By the end, you'll have a working summarizer that caches results, stores them in a NoSQL database, and can be triggered via REST API or GitHub webhooks—all deployed with a single command.

The messy reality of LLM pipelines (the problems)
If you've built more than one "simple" AI script, you've likely met these issues:
- State between steps – You call an LLM, call another API, then need to combine/compare results. Where do you keep ephemeral state? How do you re-run a step without duplicating side effects?
- Retries and idempotency – A single transient 500 from a model provider shouldn't break the whole chain or duplicate writes.
- OpenAI API integration complexity – Rate limits, token counting, model selection, error handling, and cost management add layers of complexity beyond simple HTTP calls.
- Scheduling and triggers – Many pipelines aren't one-shot. They run hourly, daily, or react to external events via webhooks from services like GitHub, Slack, or Stripe.
- Persistence – You need a place to store inputs/outputs, cache expensive calls, and expose results to other services.
- Operational sprawl – A script becomes a service; a service becomes a DAG; suddenly you're maintaining queues, workers, YAML, and dashboards just to run a few prompts.
- Language/tool drift – Frameworks like LangChain, LlamaIndex, Haystack, or workflow engines like Airflow/Prefect/Celery are powerful, but they assume extra infrastructure and a Python-first stack. That’s perfect for some teams, but overkill for many web and product engineers who just want reliable workflows in JavaScript.
These are engineering problems, not “AI problems.” The question is: what’s the lightest reliable runtime that gives you state, retries, scheduling, and storage — without spinning up a new platform?
The middle layer we actually want
Between prompt-chaining libraries and heavyweight workflow engines, there’s a practical middle:
- Functions as steps, with a small state machine to move between them
- Built-in storage for docs and key–value cache
- Scheduling (cron-like) and webhook triggers for external events
- HTTP + webhook endpoints to start/inspect runs
- One deploy
That’s the layer Codehooks provides. You write JavaScript; the platform handles routing, persistence, and workflow state. No extra orchestrator.
Useful links: Workflow API · ChatGPT backend API prompt
OpenAI API Integration Patterns
Before diving into examples, let's establish solid patterns for OpenAI API integration in production workflows.
Error Handling & Retries
A reusable helper with retries and exponential backoff:
const callOpenAIWithRetry = async (messages, options = {}, maxRetries = 3) => {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(
'https://api.openai.com/v1/chat/completions',
{
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: options.model || 'gpt-4o-mini',
messages,
temperature: options.temperature ?? 0.3,
max_tokens: options.max_tokens ?? 300,
}),
}
);
if (response.status === 429) {
// Rate limit - exponential backoff and retry
const delay = Math.pow(2, attempt) * 1000;
if (attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
}
if (!response.ok) {
const error = await response.text().catch(() => 'Unknown error');
throw new Error(`OpenAI API error ${response.status}: ${error}`);
}
const data = await response.json();
return data?.choices?.[0]?.message?.content?.trim() || '';
} catch (error) {
if (attempt === maxRetries) throw error;
// Wait before retry for network errors
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
}
}
};
Cost Optimization
- Model selection: Use
gpt-4o-minifor most tasks (significantly cheaper than GPT‑4 while still very capable) - Token management: Set appropriate
max_tokenslimits - Caching: Cache responses for identical inputs (shown in example below)
- Batch processing: Group similar requests when possible
Rate Limiting
OpenAI rate limits depend on your account, model, and tier. They also change over time.
Best practices:
- Treat HTTP 429 as a signal to back off and retry
- Implement exponential backoff (as in the helper above)
- Monitor limits and usage in the OpenAI dashboard
- Prefer more efficient models (like
gpt-4o-mini) where possible
Security
- Store API keys as encrypted environment variables
- Use different keys for different environments
- Monitor usage and set spending limits in OpenAI dashboard
Example — Summarizer with cache and storage
We'll build a small workflow that accepts text, checks a cache, calls OpenAI, stores the result, and returns it. We’ll also add a webhook endpoint that can summarize GitHub issues and comments.
Setup
npm install -g codehooks
coho create aipipeline
cd aipipeline
npm install codehooks-js node-fetch
coho set-env OPENAI_API_KEY 'sk-******' --encrypted
# Optional: for webhook signature verification (recommended for production)
coho set-env GITHUB_WEBHOOK_SECRET 'your-webhook-secret' --encrypted
index.js
Replace the default index.js created by coho create with this workflow code (all in one file for simplicity):
import { app, Datastore } from 'codehooks-js';
import fetch from 'node-fetch';
import crypto from 'crypto';
// Reusable OpenAI helper with retries and exponential backoff
async function callOpenAIWithRetry(messages, options = {}, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(
'https://api.openai.com/v1/chat/completions',
{
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: options.model || 'gpt-4o-mini',
messages,
temperature: options.temperature ?? 0.3,
max_tokens: options.max_tokens ?? 300,
}),
}
);
if (response.status === 429) {
// Rate limit - exponential backoff and retry
const delay = Math.pow(2, attempt) * 1000;
if (attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
}
if (!response.ok) {
const error = await response.text().catch(() => 'Unknown error');
throw new Error(`OpenAI API error ${response.status}: ${error}`);
}
const data = await response.json();
return data?.choices?.[0]?.message?.content?.trim() || '';
} catch (error) {
if (attempt === maxRetries) throw error;
// Wait before retry for network or transient errors
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
}
}
}
// Helper function to verify GitHub webhook signature using raw body
function verifyGitHubSignature(rawBody, signatureWithPrefix, secret) {
if (!signatureWithPrefix || !secret) return false;
if (!signatureWithPrefix.startsWith('sha256=')) return false;
const signature = signatureWithPrefix.slice('sha256='.length);
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// Use timingSafeEqual to avoid timing attacks
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// Workflow: summarize text and store
const summarizeWorkflow = app.createWorkflow(
'summarize',
'Summarize text using OpenAI and store the result',
{
start: async (state, goto) => {
if (!state?.text) return goto(null, { error: 'Missing text input' });
goto('checkCache', { ...state, steps: ['start'] });
},
checkCache: async (state, goto) => {
const db = await Datastore.open();
// Use MD5 hash of entire text for proper cache key
const key = `summary:${crypto
.createHash('md5')
.update(state.text)
.digest('hex')}`;
const cached = await db.get(key);
const newState = {
...state,
steps: [...(state.steps || []), 'checkCache'],
};
// If cached, skip to finish without storing duplicate
if (cached) {
return goto('finish', {
...newState,
summary: cached,
cached: true,
});
}
goto('callOpenAI', newState);
},
callOpenAI: async (state, goto) => {
const newState = {
...state,
steps: [...(state.steps || []), 'callOpenAI'],
};
// OpenAI API call via the reusable helper
const summary = await callOpenAIWithRetry(
[
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: `Summarize this:
${state.text}` },
],
{
model: 'gpt-4o-mini', // Cost-effective model for summarization
temperature: 0.3, // Lower temperature for consistent summaries
max_tokens: 300, // Limit tokens to control costs
}
);
goto('cacheAndStore', { ...newState, summary });
},
cacheAndStore: async (state, goto) => {
const db = await Datastore.open();
// Use MD5 hash for consistent cache key
const key = `summary:${crypto
.createHash('md5')
.update(state.text)
.digest('hex')}`;
// TTL is in milliseconds – 60 * 1000 = 60 seconds
await db.set(key, state.summary, { ttl: 60 * 1000 });
goto('store', {
...state,
steps: [...(state.steps || []), 'cacheAndStore'],
});
},
store: async (state, goto) => {
const db = await Datastore.open();
const doc = await db.insertOne('summaries', {
text: state.text,
summary: state.summary,
cached: !!state.cached,
source: state.source || 'api',
repository: state.repository || null,
createdAt: new Date().toISOString(),
});
goto('finish', {
...state,
...doc,
steps: [...(state.steps || []), 'store'],
});
},
finish: async (state, goto) => {
const steps = [...(state.steps || []), 'finish'];
console.log('Workflow finished');
console.log('WorkflowId:', state._id || 'N/A');
console.log('Steps executed:', steps.join(' → '));
goto(null, { ...state, steps });
},
}
);
// HTTP endpoints
// Direct API trigger for summarization
app.post('/summaries', async (req, res) => {
const result = await summarizeWorkflow.start({ text: req.body?.text });
res.json(result);
});
// Fetch stored summaries
app.get('/summaries', async (req, res) => {
const db = await Datastore.open();
const items = await db
.getMany('summaries', {}, { sort: { createdAt: -1 }, limit: 10 })
.toArray();
res.json(items);
});
// Webhook endpoint - trigger summarization from GitHub issues, comments, or generic payloads
app.post('/webhook/summarize', async (req, res) => {
// Verify webhook signature for security (GitHub-style HMAC)
if (process.env.GITHUB_WEBHOOK_SECRET) {
const signature = req.headers['x-hub-signature-256'];
const ok = verifyGitHubSignature(
req.rawBody,
signature,
process.env.GITHUB_WEBHOOK_SECRET
);
if (!ok) {
return res.status(401).json({ error: 'Invalid signature' });
}
}
// Extract text from GitHub issue webhook payload
// Supports: issue opened, issue comment, pull request, or generic text
let text = '';
let source = 'webhook';
if (req.body.issue) {
// GitHub issue or PR
text = `${req.body.issue.title}
${req.body.issue.body || ''}`;
source = `github-issue-${req.body.issue.number}`;
} else if (req.body.comment) {
// GitHub issue/PR comment
text = req.body.comment.body;
source = `github-comment-${req.body.comment.id}`;
} else {
// Generic fallback for other webhook providers
text = req.body?.content || req.body?.text || req.body?.message || '';
}
if (!text || text.trim().length === 0) {
return res
.status(400)
.json({ error: 'No text content in webhook payload' });
}
// Start workflow asynchronously
const result = await summarizeWorkflow.start({
text: text.trim(),
source,
repository: req.body.repository?.full_name || 'unknown',
});
// Return a lightweight response while the workflow runs in the background
res.json({ status: 'processing', workflowId: result._id });
});
export default app.init();
In Codehooks,
req.rawBodycontains the unparsed request body, which is required for correct webhook signature verification with providers like GitHub.
Deploy and get your endpoint
Now deploy your code and get your API endpoint:
$ coho deploy
Project: aipipeline-4cqe Space: dev
Deployed Codehook successfully! 🙌
Check your logs with: coho logs --follow
The coho info command shows your deployment details and if you add the --examples parameter it also provides example cURL commands. Here's what to look for:
$ coho info --examples
Project name: aipipeline-4cqe
Team: Amy Jones (personal account)
API endpoints:
https://funny-wizard-xyz9.codehooks.io
https://aipipeline-4cqe.api.codehooks.io/dev
https://api.example.com
Examples in cURL:
curl -H 'x-apikey: 73c53e83-c8b5-4b5f-8452-65ebca859ac1' -H 'Content-Type: application/json' https://funny-wizard-xyz9.codehooks.io/users
curl -H 'x-apikey: 73c53e83-c8b5-4b5f-8452-65ebca859ac1' -H 'Content-Type: application/json' --data-raw '{"name": "Mr. Code Hooks", "active": true }' --request POST https://funny-wizard-xyz9.codehooks.io/users
Spaces:
┌──────────────┬────────────────────────────────────────────┬───────────────┬──────┬────────────────────────────┐
│ Name │ Tokens │ Allowed Hosts │ Jwks │ Env │
├──────────────┼────────────────────────────────────────────┼───────────────┼──────┼────────────────────────────┤
│ dev (active) │ xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (RW) │ │ │ OPENAI_API_KEY=(encrypted) │
└──────────────┴────────────────────────────────────────────┴───────────────┴──────┴────────────────────────────┘
API Endpoint: Use any of the URLs listed. The first one is a unique random name for the space. The second one (https://aipipeline-4cqe.api.codehooks.io/dev) includes the project name and space name. The third one is a custom domain you can set up for your project.
API Token: Copy one of the tokens from the Tokens column in the Spaces table (the UUID values marked with RW for read-write access). Use this token as the value for the x-apikey header in your API calls.
Testing the workflow
You can test the workflow using cURL commands, a reusable shell script, or API clients like Bruno, Postman, or Insomnia.
Option 1: Quick tests with cURL
Replace the URL and token with your values from coho info:
# Start a summarization
curl -X POST https://aipipeline-4cqe.api.codehooks.io/dev/summaries \
-H "Content-Type: application/json" \
-H "x-apikey: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \
-d '{
"text": "Artificial intelligence is transforming software development. Modern AI tools help developers write code faster, debug more efficiently, and build smarter applications. From code completion to automated testing, AI is becoming an essential part of the developer toolkit."
}'
# Response example:
# {
# "_id": "abc123...",
# "text": "Artificial intelligence is transforming...",
# "summary": "AI tools are revolutionizing software development by enhancing code writing, debugging, and application intelligence.",
# "cached": false,
# "source": "api",
# "repository": null,
# "createdAt": "2025-11-15T10:30:00.000Z"
# }
# Fetch stored summaries
curl https://aipipeline-4cqe.api.codehooks.io/dev/summaries \
-H "x-apikey: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# Trigger via webhook (simulating GitHub issue webhook)
curl -X POST https://aipipeline-4cqe.api.codehooks.io/dev/webhook/summarize \
-H "Content-Type: application/json" \
-d '{
"action": "opened",
"issue": {
"number": 123,
"title": "Application crashes on startup",
"body": "When I run the application, it immediately crashes with error code 500. This happens on both macOS and Linux."
},
"repository": {
"full_name": "myorg/myrepo"
}
}'
Option 2: API clients (Bruno, Postman, Insomnia)
For a more visual testing experience, use API clients. All three support importing cURL commands - just copy any of the cURL examples from Option 1 above and use the "Import from cURL" feature in your preferred tool.
Option 3: Reusable shell script
For easier repeated testing, create a summarize.sh script that handles text via argument, stdin, or uses sample text:
#!/bin/bash
PROJECTNAME=your-project-name
API_KEY="your-api-key"
# Check if text is provided as argument
if [ -n "$1" ]; then
TEXT="$1"
echo "Original text length: ${#TEXT} characters"
elif [ ! -t 0 ]; then
# Read from stdin if available (not a TTY)
TEXT=$(cat)
echo "Read from stdin, length: ${#TEXT} characters"
else
# Use a sample text
TEXT="Artificial intelligence is transforming software development..."
echo "Using sample text"
fi
# Escape the text for JSON
TEXT_ESCAPED=$(echo "$TEXT" | jq -Rs .)
curl -X POST https://${PROJECTNAME}.api.codehooks.io/dev/summaries \
-H "Content-Type: application/json" \
-H "x-apikey: $API_KEY" \
-d "{\"text\": $TEXT_ESCAPED}"
Usage examples:
# Make it executable
chmod +x summarize.sh
# Use sample text
./summarize.sh
# Provide text as argument
./summarize.sh "Your custom text here"
# Read from file via stdin
cat article.txt | ./summarize.sh
./summarize.sh < article.txt
# Test with long texts without truncation
echo "Very long text content..." | ./summarize.sh
When testing with long texts, always use stdin (piping or redirection) rather than command-line arguments. Shell arguments have length limits that can truncate your text. The script automatically detects the input method and reports text length before sending.
Monitoring workflow execution
After running your workflows, you can inspect their execution status with live updates:
# One-time snapshot
coho workflow-status
# Live updates (recommended) - updates every 2 seconds
coho workflow-status --follow
The coho workflow-status --follow command provides live monitoring of your workflows, updating every 2 seconds. This is invaluable for watching workflows execute in real-time:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Workflow Status (Live) - Press Ctrl+C to exit
Last updated: 20:22:08 | Next update in 2s
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Workflow Report
===============
Total workflow instances: 4
Unique steps: 6
Workflow Structure (6 steps):
1. start
2. checkCache
3. callOpenAI
4. cacheAndStore
5. store
6. finish
Current State Distribution:
start checkCache callOpenAI cacheAndStore store finish
------------ -------------- -------------- ----------------- ------------ ------------
1 0 1 0 0 2
What this tells you:
- Live updates: See workflows progress through steps in real-time
- Step sequence: The actual execution path through your workflow
- Total instances: How many times the workflow has been triggered
- State distribution: Where each workflow instance ended up (all 2 completed at
finishin this example) - Debugging: If workflows get stuck, you'll see them stopped at intermediate steps
- Cache verification: Watch the difference between cached (start → checkCache → finish) and uncached (full path) executions
This visibility is crucial for debugging production workflows and understanding execution patterns. Use --follow during development and testing to see your workflows in action!
Flow
Why this solves real problems
- State lives in
stateand Datastore; steps remain small and testable. - Retries can be added per step (re-run from
callOpenAIwithout duplicatingstore). - Scheduling is one line with
app.job()if you want periodic summaries. - Webhooks let you react to external events like GitHub issues without polling.
Key implementation details
MD5 hash for cache keys: Using crypto.createHash('md5').update(state.text).digest('hex') creates a unique, consistent cache key based on the entire text content.
Proper cache flow: When a cached summary is found, the workflow goes directly to the finish step instead of creating a duplicate database record. This prevents storing the same summary multiple times while still allowing cache hits to avoid expensive OpenAI API calls. The TTL is set to 60 seconds, so the summary will be refreshed after 60 seconds. This is a trade-off between freshness and cost. You can change the TTL to your needs.
Finish step with tracking: Every workflow path ends at a finish step that logs:
- The workflowId for debugging
- The execution path taken (e.g.,
start → checkCache → finishfor cached vsstart → checkCache → callOpenAI → cacheAndStore → store → finishfor uncached)
This makes it easy to verify caching is working and debug any issues.
Step tracking: Each step adds itself to a steps array in the state, providing full visibility into the workflow execution path. This is invaluable for debugging and understanding how workflows behave in production.
Extending to multi-step workflows
To add more LLM calls (e.g., classify → summarize → sentiment analysis), simply add more steps to the workflow. Each step receives the state from the previous step and can add new properties before calling goto():
classify: async (state, goto) => {
// Call OpenAI for classification
const category = await callOpenAIWithRetry(
[{ role: 'user', content: 'Classify this: ' + state.text }],
{ model: 'gpt-4o-mini' }
);
goto('summarize', {
...state,
category,
steps: [...(state.steps || []), 'classify'],
});
},
summarize: async (state, goto) => {
// Use category in the prompt
const summary = await callOpenAIWithRetry(
[
{
role: 'user',
content: `Summarize this ${state.category} text: ${state.text}`,
},
],
{ model: 'gpt-4o-mini' }
);
goto('sentiment', {
...state,
summary,
steps: [...(state.steps || []), 'summarize'],
});
},
sentiment: async (state, goto) => {
const sentiment = await callOpenAIWithRetry(
[{ role: 'user', content: 'Analyze sentiment: ' + state.text }],
{ model: 'gpt-4o-mini' }
);
goto('store', {
...state,
sentiment,
steps: [...(state.steps || []), 'sentiment'],
});
},
The pattern remains the same regardless of how many steps you chain together. Split steps into separate files for larger workflows.
Webhook-Triggered Workflows
Webhooks enable event-driven AI workflows. External services (GitHub, Slack, Stripe) can trigger workflows automatically when events occur — no polling required.
Key Benefits
- Real-time processing: Trigger AI analysis immediately when data becomes available
- Integration-friendly: Most SaaS platforms support outbound webhooks
- Efficient: Event-driven beats constant polling
Example: GitHub Issue Triage (variation)
Here’s a variation on the previous webhook example, where a workflow triages newly opened issues. You can reuse the same verifyGitHubSignature helper from the main index.js example above.
app.post('/webhook/github', async (req, res) => {
// Verify signature using req.rawBody
if (process.env.GITHUB_WEBHOOK_SECRET) {
const signature = req.headers['x-hub-signature-256'];
const ok = verifyGitHubSignature(
req.rawBody,
signature,
process.env.GITHUB_WEBHOOK_SECRET
);
if (!ok) {
return res.status(401).json({ error: 'Invalid signature' });
}
}
const event = req.body;
if (event.action === 'opened' && event.issue) {
// Classify and tag new issues automatically
await issueTriageWorkflow.start({
title: event.issue.title,
body: event.issue.body,
issueNumber: event.issue.number,
repository: event.repository.full_name,
});
}
res.json({ received: true });
});
Webhook Best Practices
- Always verify signatures using
req.rawBody(not parsed JSON) - Acknowledge fast: Return 200 quickly, process asynchronously
- Handle duplicates: Use webhook IDs for idempotency
- Security: Store webhook secrets as encrypted environment variables
Workflow triggers and access patterns
Workflows can be triggered through multiple channels, each suited to different use cases:
Direct API Access
- REST endpoints for direct workflow invocation
- Workflow API for programmatic workflow management via the Workflow API
- Perfect for integrating with other services or AI agents
Webhook Triggers
- External events from third-party services (GitHub, Slack, Stripe) trigger workflows automatically
- Built-in webhook signature verification with
req.rawBody - Asynchronous processing with immediate acknowledgment
- Perfect for event-driven automation
Trigger Comparison
| Trigger Type | Use Case | Best For |
|---|---|---|
| REST API | Direct invocation | User-initiated actions, manual triggers |
| Webhooks | External events | GitHub issues, Slack messages, payment events |
| Scheduled Jobs | Time-based | Periodic summaries, daily reports, cleanup tasks |
For interactive prompting setups, see: ChatGPT backend API prompt.
When you might still want heavier tooling
You might still want Airflow, Prefect, or other orchestration platforms when you have:
- Complex data pipelines with distributed processing and backfills
- Strict SLAs that require fine-grained backpressure control
- Python-native model ops where LangChain integrations are central
Codehooks can still host the execution layer for these systems (expose endpoints, persist results, schedule jobs), but it's not a replacement for large ETL platforms.
Wrap-up
If the hard part of your AI project is connecting steps reliably—not training models—then a light workflow runtime is usually enough. Codehooks gives you small functions, a state machine, storage, and a single deploy target.
These LLM workflows can be triggered and managed through APIs, CLI, or webhooks—giving you flexible control while maintaining clear operational boundaries.
- Start with one file and one command
- Add steps as your logic grows
- Trigger via REST API, webhooks, or scheduled jobs
If you’re already calling OpenAI from Node, you’re only a few minutes away from turning your script into a resilient workflow.
LLM Workflow FAQ
Common questions about building LLM workflows, orchestration, and OpenAI API integration
What is an LLM workflow?
How is this different from LangChain or LlamaIndex?
How do webhooks work with AI workflows?
What does OpenAI API integration cost in these workflows?
Can I use LLM providers other than OpenAI?
When should I use Codehooks instead of Airflow or Prefect?
What are the benefits of the Workflow API?
Can I use Codehooks with AI coding agents like ChatGPT or Claude?
Useful links: Workflow API · ChatGPT backend API prompt
