- AI CODING CLUB
- Posts
- 🙇‍♂️ Sorry, I've been sooooo busy...
🙇‍♂️ Sorry, I've been sooooo busy...
I've had a few very active weeks of AI-assisted coding. Here are some insights.
When I started this newsletter back in March, I thought I would be able to send one edition per week. I was VERY optimistic.
Life has been pretty hectic recently.
Moving houses, working for clients, launching new features for the AI Jingle Maker, incl. sung jingles (a managed service) and crafting a presentation about AI for a class of 6th formers (link at the end of this email).
To be honest, I never thought I would be so busy when turning 50 (= on the 8th of June if you want to pencil my next birthday in your agenda ;-)).
In this edition, I’d like to share with you some of the insights from my most recent coding sessions (I suggest you click on the Read Online button at the top of the message if this message gets clipped by your email client).
Launching a Radio Stations Directory with Flask
On Wednesday morning I decided it was time for me to add a Radio Stations Directory on the AI Jingle Maker platform.
I have more than 20,000 AIJM users in the mailing list and I thought it would be a nice “goodwill feature” to introduce to my community. Also, it can help with SEO, which doesn’t hurt.
The idea was to offer a super easy way to promote your indie radio station.
This new feature would be hooked to my current Flask authentication system, so that existing users would just have to open the registration page to add their station, no need to set up a brand new signup/login logic, the directory is fully integrated with the app.
I decided to create a basic one-page CMS (Content Management System) with a quick EDIT / PREVIEW toggle to make it as WYSIWYG as possible (What You See Is What You Get).
a portion of the one-page CMS
Since you’re here for some AI-assisted coding tips, let me share with you the exact prompt I gave to ChatGPT 4o to kickstart my coding session on Wednesday afternoon.
The aim was to create the mysql table recording radio station entries.
I shared with my assistant a reference of my existing DB setup, in order to get a script adapted to my server configuration.
Based on the following DB connection logic I have in my code:
import os
import mysql.connector
from dotenv import load_dotenv
load_dotenv()
# Global database configuration
DATABASE_CONFIG = {
'user': 'root',
'password': os.environ.get('MYSQL_PASSWORD'),
'host': 'host.on.railway',
'port': '12345',
'database': 'railway'
}
def get_db():
return mysql.connector.connect(**DATABASE_CONFIG)
def execute_query(query, params=()):
try:
db_conn = mysql.connector.connect(**DATABASE_CONFIG)
cursor = db_conn.cursor()
cursor.execute(query, params)
db_conn.commit()
cursor.close()
db_conn.close()
print("Query executed successfully.")
except mysql.connector.Error as err:
print(f"Error: {err}")
Give me the code to create a brand new table in the DB called "entries" (for directory entries), with the following columns:
id (auto incremented)
user_id (integer)
radio_name (text)
website_url (text)
stream_url (text)
city (text)
zipcode (text)
country (text)
street_and_number (text)
description (long text)
fb_page_url (text)
ig_account_url (text)
x_account_url (text)
yt_channel_url (text)
mixcloud_account_url (text)
soundcloud_account_url (text)
logo_name (text)
language (text)
ChatGPT replied in a few seconds
import os
import mysql.connector
from dotenv import load_dotenv
load_dotenv()
# Global database configuration
DATABASE_CONFIG = {
'user': 'root',
'password': os.environ.get('MYSQL_PASSWORD'),
'host': 'host.on.railway',
'port': '12345',
'database': 'railway'
}
def get_db():
return mysql.connector.connect(**DATABASE_CONFIG)
def execute_query(query, params=()):
try:
db_conn = mysql.connector.connect(**DATABASE_CONFIG)
cursor = db_conn.cursor()
cursor.execute(query, params)
db_conn.commit()
cursor.close()
db_conn.close()
print("Query executed successfully.")
except mysql.connector.Error as err:
print(f"Error: {err}")
# SQL query to create the "entries" table
create_table_query = """
CREATE TABLE entries (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
radio_name TEXT,
website_url TEXT,
stream_url TEXT,
city TEXT,
zipcode TEXT,
country TEXT,
street_and_number TEXT,
description LONGTEXT,
fb_page_url TEXT,
ig_account_url TEXT,
x_account_url TEXT,
yt_channel_url TEXT,
mixcloud_account_url TEXT,
soundcloud_account_url TEXT,
logo_name TEXT,
language TEXT
);
"""
# Execute the query to create the table
execute_query(create_table_query)
ChatGPT also gave me the HTML and the JAVASCRIPT to configure the actual form.
Here’s the HTML. You will notice that I’m using Sweet Alert 2 for all my notifications.
hls.js is a JS library used to process some of the audio streams declared by my users (as a fallback to the default web audio API).
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Directory Entry</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='directory_entry.css') }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
</head>
<body class="bg-gray-100">
<div class="container mx-auto p-4 max-w-5xl">
<div class="flex justify-between items-center mb-4">
<button id="toggleButton" class="bg-blue-500 text-white p-2 rounded">Preview</button>
<button id="saveButton" class="bg-green-500 text-white p-2 rounded">Save</button>
</div>
<form id="directoryEntryForm" class="bg-white p-6 rounded shadow-md mx-auto" style="max-width: 800px;">
<div id="previewMode" class="hidden">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="col-span-1">
<img id="logoPreview" src="" alt="Logo Preview" class="mt-2 max-w-xs hidden">
<h1 id="radioNamePreview" class="text-2xl font-bold mt-4"></h1>
<p id="descriptionPreview" class="mt-4 mb-4"></p>
<p id="websitePreview" class="mt-4 mb-4"><i class="fas fa-globe"></i> <a href="" id="websiteLink" target="_blank"></a></p>
<button type="button" id="playStreamPreview" class="mt-4 mb-4 bg-blue-500 text-white p-2 rounded">Play Stream</button>
<canvas id="visualizer" class="w-full h-24 mt-2 hidden"></canvas>
<p id="languageGenrePreview" class="mt-2"></p>
<p id="locationPreview" class="mt-2"></p>
</div>
<div class="col-span-1">
<img id="imagePreview" src="" alt="Custom Image Preview" class="mt-2 hidden">
<div id="socialLinks" class="mt-4 hidden">
<h2 class="block text-gray-700 mt-4 mb-4">Follow us on Socials</h2>
<div id="socialIcons" class="flex space-x-4"></div>
</div>
</div>
</div>
</div>
<div id="editMode">
<h2 class="mb-6 text-2xl text-blue-500 font-bold">Create / update your free Radio Station directory entry.</h2>
<p class="text-xs mb-4">Fill out the mandatory fields (note: socials are optional), then hit the green SAVE button at the top of the page. You can preview the result at any time.</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="col-span-1">
<div class="mb-4">
<label for="logo" class="block text-gray-700">Logo (Mandatory. Transparent PNG, max 300px wide)</label>
<input type="file" id="logo" name="logo" accept="image/png" class="mt-1 p-2 w-full border rounded" required>
<img id="logoPreviewEdit" src="" alt="Logo Preview" class="mt-2 max-w-xs hidden">
</div>
<div class="mb-4">
<label for="radio_name" class="block text-gray-700">Radio Name (Mandatory)</label>
<input type="text" id="radio_name" name="radio_name" class="mt-1 p-2 w-full border rounded" required>
</div>
<div class="mb-4">
<label for="description" class="block text-gray-700">Description (Mandatory)</label>
<textarea id="description" name="description" class="mt-1 p-2 w-full border rounded" required></textarea>
</div>
<div class="mb-4">
<label for="website_url" class="block text-gray-700">Website URL (Mandatory. Incl. https://)</label>
<input type="url" id="website_url" name="website_url" class="mt-1 p-2 w-full border rounded" required>
</div>
<div class="mb-4">
<label for="stream_url" class="block text-gray-700">Direct Stream URL (Mandatory. Incl. https://)</label>
<p class="text-xs mt-2 mb-2">Test the stream to make sure it's working before saving the page. If you struggle to find a functional streaming URL, <a href="mailto:[email protected]" class="text-blue-500 font-bold">send me an email.</a></p>
<input type="url" id="stream_url" name="stream_url" class="mt-1 p-2 w-full border rounded">
<button type="button" id="playStream" class="mt-2 bg-blue-500 text-white p-2 rounded">Play Stream</button>
</div>
<div class="mb-4">
<label for="language" class="block text-gray-700">Main Broadcasting Language (Mandatory)</label>
<select id="language" name="language" class="mt-1 p-2 w-full border rounded">
<option value="english">English</option>
<option value="french">French</option>
<option value="german">German</option>
<option value="italian">Italian</option>
<option value="spanish">Spanish</option>
</select>
</div>
<div class="mb-4">
<label for="genre" class="block text-gray-700">Genre (Mandatory)</label>
<input type="text" id="genre" name="genre" class="mt-1 p-2 w-full border rounded">
</div>
<div class="mb-4">
<label for="country" class="block text-gray-700">Country (Mandatory)</label>
<select id="country" name="country" class="mt-1 p-2 w-full border rounded" required>
<!-- Dynamically populate country options using JavaScript -->
</select>
</div>
<div class="mb-4">
<label for="street_and_number" class="block text-gray-700">Street and Number</label>
<input type="text" id="street_and_number" name="street_and_number" class="mt-1 p-2 w-full border rounded">
</div>
<div class="mb-4">
<label for="zipcode" class="block text-gray-700">Zipcode</label>
<input type="text" id="zipcode" name="zipcode" class="mt-1 p-2 w-full border rounded">
</div>
<div class="mb-4">
<label for="city" class="block text-gray-700">City (Mandatory)</label>
<input type="text" id="city" name="city" class="mt-1 p-2 w-full border rounded" required>
</div>
</div>
<div class="col-span-1">
<div class="mb-4">
<label for="image" class="block text-gray-700">Custom Image (Mandatory. PNG or JPEG, max 1Mb)</label>
<p class="text-xs mt-2 mb-2">Don't be lazy, don't use the same image for the logo and the custom image ;-). You can use MidJourney to create a cool custom image.</p>
<input type="file" id="image" name="image" accept="image/png, image/jpeg" class="mt-1 p-2 w-full border rounded" required>
<img id="imagePreviewEdit" src="" alt="Custom Image Preview" class="mt-2 hidden">
</div>
<div class="mb-4">
<label for="fb_page_url" class="block text-gray-700">Facebook Page URL (incl. https://)</label>
<input type="url" id="fb_page_url" name="fb_page_url" class="mt-1 p-2 w-full border rounded">
</div>
<div class="mb-4">
<label for="ig_account_url" class="block text-gray-700">Instagram Account URL (incl. https://)</label>
<input type="url" id="ig_account_url" name="ig_account_url" class="mt-1 p-2 w-full border rounded">
</div>
<div class="mb-4">
<label for="x_account_url" class="block text-gray-700">X (Twitter) Account URL (incl. https://)</label>
<input type="url" id="x_account_url" name="x_account_url" class="mt-1 p-2 w-full border rounded">
</div>
<div class="mb-4">
<label for="yt_channel_url" class="block text-gray-700">YouTube Channel URL (incl. https://)</label>
<input type="url" id="yt_channel_url" name="yt_channel_url" class="mt-1 p-2 w-full border rounded">
</div>
<div class="mb-4">
<label for="mixcloud_account_url" class="block text-gray-700">Mixcloud Account URL (incl. https://)</label>
<input type="url" id="mixcloud_account_url" name="mixcloud_account_url" class="mt-1 p-2 w-full border rounded">
</div>
<div class="mb-4">
<label for="soundcloud_account_url" class="block text-gray-700">Soundcloud Account URL (incl. https://)</label>
<input type="url" id="soundcloud_account_url" name="soundcloud_account_url" class="mt-1 p-2 w-full border rounded">
</div>
</div>
</div>
</div>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@10"></script>
<script src="{{ url_for('static', filename='directory_entry.js') }}"></script>
</body>
</html>
It’s the latest iteration of what you’ll experience if you visit https://www.aijinglemaker.com/add_directory_entry (you have to be logged in to access the page).
You may have noticed that we have 2 versions for the on-page content, spread in 2 DIV, editMode and previewMode, which correspond to the 2 toggleable views.
As you know, when it comes to UX, the quality of the experience is in the little details, like the visual cues you give to the user if something goes wrong or the right message to keep them patient.
On this page, I’ve asked ChatGPT to highlight in red the mandatory fields when not filled out when the user attempts to validate the form.
Here’s the validation function from the JS file.
const validateForm = () => {
const requiredFields = [
'radio_name',
'stream_url',
'logo',
'image',
'description',
'website_url',
'city',
'language',
'genre',
'country'
];
let isValid = true;
requiredFields.forEach(field => {
const input = document.getElementById(field);
if (!input) {
isValid = false;
} else if (input.type === 'file') {
const preview = document.getElementById(`${field}PreviewEdit`);
if ((!input.files || input.files.length === 0) && (!preview || !preview.src || preview.src === window.location.href)) {
isValid = false;
input.classList.add('bg-red-500');
} else {
input.classList.remove('bg-red-500');
}
} else if (input.value.trim() === '') {
isValid = false;
input.classList.add('bg-red-500');
} else {
input.classList.remove('bg-red-500');
}
});
return isValid;
};
You’ve noticed that I’m using Tailwind CSS classes (bg-red-500).
That’s how an empty page looks like if you try to save it.
The red highlights disappear when you fill out the fields.
Field Validation
When all my CMS logic was ready for manual inputs, I also asked ChatGPT to create a Python script for bulk data imports (which I used to add 12 entries before promoting the feature).
Some of the stations seeded into the directory via a Bulk import
Here’s the full prompt and the result.
I'll give you as reference a function we use to add entries in our radio stations directory via the website.
I want you to adapt this as a standalone Python script which will parse rows from a radiostations.csv file with column names corresponding to the mysql table column names, in order to bulk populate the table
def add_entry():
user_id = current_user.id
radio_name = request.form.get('radio_name')
website_url = request.form.get('website_url')
stream_url = request.form.get('stream_url')
city = request.form.get('city')
zipcode = request.form.get('zipcode')
country = request.form.get('country')
street_and_number = request.form.get('street_and_number')
description = request.form.get('description')
fb_page_url = request.form.get('fb_page_url')
ig_account_url = request.form.get('ig_account_url')
x_account_url = request.form.get('x_account_url')
yt_channel_url = request.form.get('yt_channel_url')
mixcloud_account_url = request.form.get('mixcloud_account_url')
soundcloud_account_url = request.form.get('soundcloud_account_url')
language = request.form.get('language')
genre = request.form.get('genre')
logo = request.files.get('logo')
if logo and logo.filename.endswith('.png'):
uid = str(uuid.uuid4())
logo_filename = f"{radio_name}_logo_{uid}.png"
logo_path = os.path.join('/tmp', logo_filename)
logo.save(logo_path)
with open(logo_path, 'rb') as file:
s3.upload_fileobj(file, IMAGE_BUCKET_NAME, logo_filename, ExtraArgs={'ContentType': 'image/png'})
logo_name = logo_filename
else:
return jsonify({'success': False, 'message': 'Invalid logo file.'})
image_prompt = f"Conceptual illustration for {radio_name} playing {genre} music in {city} {country}. No text on the image."
image_path = generate_stable_image(image_prompt, user_id)
image_filename = f"{radio_name}_custom_image_{uid}.png"
with open(image_path, 'rb') as file:
s3.upload_fileobj(file, IMAGE_BUCKET_NAME, image_filename, ExtraArgs={'ContentType': 'image/png'})
image_name = image_filename
# Check if user already has an entry
query_check = "SELECT id FROM entries WHERE user_id = %s"
db_conn = mysql.connector.connect(**DATABASE_CONFIG)
cursor = db_conn.cursor()
cursor.execute(query_check, (user_id,))
entry = cursor.fetchone()
cursor.close()
db_conn.close()
columns = [
'radio_name', 'website_url', 'stream_url', 'city', 'zipcode', 'country',
'street_and_number', 'description', 'fb_page_url', 'ig_account_url',
'x_account_url', 'yt_channel_url', 'mixcloud_account_url',
'soundcloud_account_url', 'logo_name', 'image_name', 'language', 'genre'
]
data = {
'radio_name': radio_name, 'website_url': website_url, 'stream_url': stream_url,
'city': city, 'zipcode': zipcode, 'country': country, 'street_and_number': street_and_number,
'description': description, 'fb_page_url': fb_page_url, 'ig_account_url': ig_account_url,
'x_account_url': x_account_url, 'yt_channel_url': yt_channel_url, 'mixcloud_account_url': mixcloud_account_url,
'soundcloud_account_url': soundcloud_account_url, 'logo_name': logo_name, 'image_name': image_name,
'language': language, 'genre': genre
}
if entry:
# Update existing entry
update_columns = [f"{col} = %s" for col in columns if data[col] is not None]
query_update = f"""
UPDATE entries
SET {', '.join(update_columns)}
WHERE user_id = %s
"""
params = [data[col] for col in columns if data[col] is not None] + [user_id]
if execute_entry_query(query_update, params):
return jsonify({'success': True})
else:
return jsonify({'success': False, 'message': 'Failed to update entry in the database.'})
else:
# Insert new entry
insert_columns = [col for col in columns if data[col] is not None]
query_insert = f"""
INSERT INTO entries (user_id, {', '.join(insert_columns)})
VALUES (%s, {', '.join(['%s'] * len(insert_columns))})
"""
params = [user_id] + [data[col] for col in insert_columns]
if execute_entry_query(query_insert, params):
return jsonify({'success': True})
else:
return jsonify({'success': False, 'message': 'Failed to add entry to the database.'})
There was just a little mistake in the original bulk creation script suggested by ChatGPT (not its fault), because my logos and custom images were not locally available but already uploaded to S3.
My CSV was simply referencing the names from AMZ S3. I had to fine tune my prompt. Then the LLM gave me the perfect script, pretty straightforward, recording each row from the CSV into the mysql table.
import os
import mysql.connector
from dotenv import load_dotenv
import csv
load_dotenv()
# Global database configuration
DATABASE_CONFIG = {
'user': 'root',
'password': os.environ.get('MYSQL_PASSWORD'),
'host': 'host.on.railway',
'port': '12345',
'database': 'railway'
}
def get_db():
return mysql.connector.connect(**DATABASE_CONFIG)
def execute_entry_query(query, params=()):
try:
db_conn = mysql.connector.connect(**DATABASE_CONFIG)
cursor = db_conn.cursor()
cursor.execute(query, params)
db_conn.commit()
cursor.close()
db_conn.close()
except mysql.connector.Error as err:
print(f"Error: {err}")
return False
return True
def parse_and_insert_entries(csv_file_path):
columns = [
'user_id', 'radio_name', 'website_url', 'stream_url', 'city', 'zipcode', 'country',
'street_and_number', 'description', 'fb_page_url', 'ig_account_url',
'x_account_url', 'yt_channel_url', 'mixcloud_account_url',
'soundcloud_account_url', 'logo_name', 'image_name', 'language', 'genre'
]
with open(csv_file_path, mode='r', encoding='utf-8') as csvfile:
csv_reader = csv.DictReader(csvfile)
for row in csv_reader:
data = {col: row[col] for col in columns if col in row and row[col]}
insert_columns = [col for col in columns if col in data]
query_insert = f"""
INSERT INTO entries ({', '.join(insert_columns)})
VALUES ({', '.join(['%s'] * len(insert_columns))})
"""
params = [data[col] for col in insert_columns]
if not execute_entry_query(query_insert, params):
print(f"Failed to add entry for {row['radio_name']}.")
csv_file_path = 'radiostations.csv'
print(f"Parsing and inserting entries from {csv_file_path}.")
parse_and_insert_entries(csv_file_path)
print("Done.")
ChatGPT also generated the CSV template, to make sure I used the exact same names for the columns.
In terms of UX details, after the initial release, I asked ChatGPT to add a state to the PLAY STREAM button, to keep the users patient while the stream is buffering.
Could we improve the UX and have the "Play stream" button text turn to "Buffering..." while it's buffering the stream, then to "Stop stream" when it's actually playing?
When in "Buffering mode..." disable the click (reactivate it for "Stop stream").
Here’s an excerpt from the JS code fully written by ChatGPT, incl. the HLS playback option (one of 4 options attempted in sequence: Audio, HLS, DASH, Video).
const playStream = (streamUrl, button) => {
if (button.textContent === 'Stop Stream') {
stopStream(button);
return;
}
if (audio) {
audio.pause();
}
button.textContent = 'Buffering...';
button.disabled = true;
const attemptPlayHLS = () => {
if (Hls.isSupported()) {
audio = document.createElement('audio');
hls = new Hls();
hls.loadSource(streamUrl);
hls.attachMedia(audio);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
audio.play()
.then(() => {
button.textContent = 'Stop Stream';
button.classList.remove('bg-blue-500');
button.classList.add('bg-red-500');
button.disabled = false; // Reactivate the button
setUpVisualizer(audio);
isStreamPlaying = true;
})
.catch((error) => {
console.error('Error playing HLS stream:', error);
Swal.fire('Error', 'Unable to play HLS stream.', 'error');
});
});
//the code continues
Here’s the code in action.
Play, Buffering, Stop
One last snippet
I’ve got one last piece of code for you today.
Sometimes my users only provide HTTP streaming URLs, not HTTPS.
That’s a problem (which triggers a JS error) since my code only plays streams from secure HTTPS origins.
The workaround was to create a Proxy server to convert those HTTP URLs into HTTPS on the fly.
Again, I asked my coding assistant to help me and I received in a few seconds the code of a fully functional Flask web app.
You can for instance use it on Replit or Google CloudRun.
import os
from flask import Flask, request, Response
import requests
from flask_cors import CORS
app = Flask(__name__)
CORS(app) # Enable CORS for all routes
@app.route('/')
def index():
return '''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <!-- Adding responsiveness -->
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/tailwind.min.css" rel="stylesheet"> <!-- Adding Tailwind CSS via CDN -->
<title>HTTP to HTTPS Stream Proxy</title>
</head>
<body class="p-8"> <!-- Adding some padding to the body -->
<h1 class="text-4xl text-center mb-8">HTTP to HTTPS Stream Proxy</h1> <!-- Centering and increasing the size of the title -->
<form action="/stream" method="get" class="mx-auto w-full max-w-xs"> <!-- Centering the form and limiting its width -->
<label for="url" class="block mb-2">Enter HTTP Stream URL:</label> <!-- Making the label a block element and adding some margin -->
<input type="text" id="url" name="url" required class="w-full p-2 mb-4 border border-gray-300 rounded"> <!-- Making the input full width, adding some padding, border and rounded corners -->
<button type="submit" class="w-full p-2 bg-blue-500 text-white rounded">Get HTTPS Stream</button> <!-- Making the button full width, adding some padding, blue background and white text -->
</form>
</body>
</html>
'''
@app.route('/stream')
def stream():
url = request.args.get('url')
if not url:
return "No URL provided", 400
response = requests.get(url, stream=True)
return Response(response.iter_content(chunk_size=1024), content_type=response.headers['Content-Type'])
if __name__ == '__main__':
app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 5000)))
Here’s what you’ll see in your browser when you’ll run this code.
The AI Debate Presentation
One last thing before I leave you today.
I’ve promised to some readers to share the full presentation titled “The AI Debate” I gave to a class of 6th formers in early July.
Here’s the link, incl. excerpts from insightful video interviews of Geoffrey Hinton, Roman Yampolskiy and Yann Lecun: https://bit.ly/theaidebate
The presentation features a section dedicated to my personal AI-assisted coding journey. Spoiler: it ends on an optimistic note.
Until next time, Happy Coding!
Frédérick
If you’d like to get a private introduction to the art of AI-assisted coding and more broadly a detailed overview of today’s Gen AI capabilities, I’m offering one-on-one 2-hour mentoring sessions “How To Talk To An AI Agent”.
Here’s the link for bookings.
Sessions are tailored to your specific business needs.
I can also assist you in the development of your own micro SaaS project.