When I started my AWS Cloud Path series I found myself stuck in a pattern where I have a livestream, publish it, tell myself I’ll “write the blog version later,” and then never quite get to it because of a backlog. A 60-120 minute tutorial can easily turn into several hours of rewatching, drafting, editing, and formatting a decent article version.
For me, that friction meant only a small fraction of my YouTube content had made it to articles at the time. I wanted a pipeline that would take a YouTube tutorial, extract the content I had shared, and turn them into a structured technical article (headings, code blocks, my warnings and explanations included). Hermes.v was my attempt to automate that pipeline while still keeping enough control to edit and improve the final draft as needed.
In this post, I’ll walk you through building this project, the architecture behind it, and how it works end-to-end. By the end, you’ll see how you can deploy the same stack and adapt it to your own content.
What Hermes.v does
elizabethadegbaju
/
hermes.v
Converts YouTube tutorial videos into Dev.to articles using AWS Bedrock and Strands Agents.
Hermes.v — YouTube Tutorial Article Generator
A Strands Agents powered autonomous agent that converts YouTube tutorial videos into published technical articles on Dev.to.
How it works
- The agent asks you for a YouTube URL and optional title and tags
- It inspects video metadata and decides whether visual extraction is worth the cost — frames are extracted for code walkthroughs and demos, skipped for talk-style videos
- Audio is uploaded to S3 and transcribed via Amazon Transcribe
- The transcript is split into chunks; structured notes are extracted from each chunk, then condensed into an outline
- Claude writes the article section by section, carrying a compressed summary of previous sections as context to maintain coherence
- The finished article is published as a draft to Dev.to
Architecture
AWS services used: Bedrock (Claude Sonnet 4.6), Amazon Transcribe, S3
Prerequisites
- AWS account with Bedrock Claude Sonnet 4.6 model access enabled
- AWS CDK CLI:
npm install -g…
Hermes.v is a Strands Agents–powered workflow that converts a YouTube tutorial video into a Dev.to draft article. At a high level, it takes a single input (a YouTube URL) and orchestrates several steps across AWS and third‑party APIs to produce a markdown article draft on Dev.to.
The current end‑to‑end flow looks like this:
- You provide a YouTube tutorial URL (and optionally a title and tags) when prompted.
- The agent uses
yt-dlpviaget_video_datato fetch video metadata. -
extract_frames_and_audiodownloads the video, extracts audio, and samples frames over time for visual context; the audio is uploaded to S3 and the frames are summarized into a “visual summary” text file. -
generate_transcriptpasses the audio to Amazon Transcribe, waits for completion, and saves the transcript to S3. -
generate_article_draftruns a staged drafting pipeline in_core.py: chunked transcript notes → outline → section‑by‑section article, with Bedrock Claude Sonnet 4.6 doing the heavy lifting. -
publish_devto_articlereads the article markdown from S3 and publishes it as a Dev.to draft, or skips publishing if you haven’t set an API key.
Hermes.v handles the grunt work of converting my tutorial content so I can focus on editing, and adding my own emphasis instead of writing the article from scratch.
Architecture at a glance
Conceptually, Hermes.v is a pipeline that connects YouTube, AWS services, and Dev.to, with a Strands Agent coordinating each step. The current architecture is run‑based: every processing session has a run_id and a manifest stored in S3 so the agent can skip completed steps if you rerun it.
At a glance:
- Strands Agent: orchestrates tool calls, keeps the system prompt, and manages conversation history.
-
AWS services:
- Amazon Bedrock (Claude Sonnet 4.6) for visual summarisation and article generation.
- Amazon Transcribe for audio transcription.
- Amazon S3 for run manifests, audio, transcripts, visual summaries, draft caches, and final articles.
- Dev.to API: receives the finished markdown as an unpublished draft.
Everything is wired via environment variables and an AWS CDK stack defined under infra/.
Why I used an agent instead of a script
My first version was “just a Python script”: download video, extract audio, extract frames, use Whisper (a python library) to transcribe, call Bedrock, POST to Dev.to. It worked but as soon as I tried to handle real‑world issues, it got messy.
Each “quick fix” turned into another if block or flag. I realised I was trying to encode workflow logic inside one function instead of treating each operation as a first‑class step with its own input, output, and retries.
That’s where Strands Agents came in.:
- define tools (download, extract, transcribe, draft, publish),
- give the agent a clear system prompt,
- and let it decide which tool to call next based on the run state.
This made the flow easier to reason about and much easier to extend.
AWS infrastructure and deployment
Hermes.v ships with an AWS CDK stack that provisions everything the agent needs:
- an S3 bucket for audio, transcripts, visual summaries, draft caches, and article markdown (
HERMES_S3_BUCKET), - IAM roles and permissions to call Amazon Transcribe and Amazon Bedrock,
- outputs and environment variables that wire the agent to the right region, bucket, model, and Dev.to config.
Deployment workflow (from the README):
# 1. Clone and install
git clone https://github.com/elizabethadegbaju/hermes.vcd hermes.v
make install
# 2. Bootstrap CDK (once per AWS account/region)
make bootstrap
# 3. Deploy infrastructure
make deploy
# 4. Configure environment
cp .env.example .env
# fill in .env with values, including HERMES_S3_BUCKET from `make deploy`
Environment variables include AWS_REGION, BEDROCK_MODEL_ID (default eu.anthropic.claude-sonnet-4-6), HERMES_S3_BUCKET, and optional Dev.to config (DEVTO_API_KEY, DEVTO_ORG_ID, DEVTO_MAIN_IMAGE).
The agent orchestration
The core agent wiring lives in agent/hermes_agent.py.
"""Hermes.v - YouTube Tutorial Article Generation Agent using Strands."""
import logging
import os
import boto3
from dotenv import load_dotenv
from strands import Agent
from strands.models import BedrockModel
from strands.agent.conversation_manager import SlidingWindowConversationManager
from strands_tools import handoff_to_user
from agent.tools import (
start_run,
get_video_data,
extract_frames_and_audio,
generate_transcript,
generate_article_draft,
publish_devto_article,
)
load_dotenv()
SYSTEM_PROMPT = """
You are Hermes, an autonomous workflow agent that turns YouTube tutorial videos into high-quality technical draft articles for Dev.to.
Your goal is to produce a useful, accurate, developer-friendly article draft and publish it as a Dev.to draft only when it is ready.
A good outcome is an article that is faithful to the source video, technically accurate, clearly structured, and helpful to developers. Include implementation details, commands, and code examples only when they are supported by the source material.
## What you control
You may decide:
- how much processing the video needs,
- whether visual extraction is worth the cost,
- whether the result is ready to publish,
- whether to stop and return a draft instead of publishing.
## Required behavior
- Always obtain a run_id before doing any work and pass it to every tool call.
- Ask the user for the YouTube URL and any optional title or tags before processing.
- Inspect video metadata before choosing the workflow.
- Use visual extraction when diagrams, console output, slides, or architecture visuals are likely to matter.
- Use the article generation tool to create the draft; do not write the final article directly in the conversation.
- Do not invent code, commands, steps, or technical claims not supported by the source material.
- Publish only after a successful draft generation step and only as a Dev.to draft.
- If draft generation fails or publishing is not appropriate, return the draft status and explain the next step to the user.
- Always use the handoff tool to communicate with the user, do not attempt to send messages directly in the conversation.
## Defaults
- Prefer a single article unless the video is obviously too broad or too long.
- Prefer draft safety over risky autonomy.
- If a tool reports an existing artifact for the same run, reuse it unless the user clearly asks for a fresh attempt.
"""
class HermesAgent:
def __init__(self):
session = boto3.Session(
region_name=os.environ.get("AWS_REGION", "eu-central-1")
)
bedrock_model = BedrockModel(
model_id=os.environ.get(
"BEDROCK_MODEL_ID", "eu.anthropic.claude-sonnet-4-6"
),
boto_session=session,
)
self.agent = Agent(
model=bedrock_model,
tools=[
start_run,
get_video_data,
extract_frames_and_audio,
generate_transcript,
generate_article_draft,
publish_devto_article,
handoff_to_user,
],
conversation_manager=SlidingWindowConversationManager(),
system_prompt=SYSTEM_PROMPT,
)
def run(self) -> str:
return str(
self.agent(
"Start a new Hermes run, collect the YouTube URL and optional title/tags from the user, then produce the best possible Dev.to draft using the available tools."
)
)
def main():
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
)
print(HermesAgent().run())
if __name__ == "__main__":
main()
Key points:
- The agent uses Bedrock Claude Sonnet 4.6 via
BedrockModel. - It registers all tools, including
start_runandgenerate_article_draft. - The system prompt encodes the workflow rules and safety rails.
- The
run()method kicks off a conversation where the agent first asks you for a URL/title/tags viahandoff_to_user, then orchestrates the rest.
Tools: from YouTube URL to S3 artifacts
All tools live in agent/tools.py. They use shared helpers from _core.py for S3 access, manifests, and Bedrock calls.dev
Starting a run
Each session begins with start_run, which creates a manifest and a run_id:
import time
from typing import Dict
from strands import tool
from agent._core import save_manifest
@tool
def start_run() -> Dict[str, str]:
"""Create and return a new run_id for this processing session."""
run_id = f"hermes-{int(time.time())}"
save_manifest(run_id, {"created_at": int(time.time())})
return {"run_id": run_id}
The manifest is stored at hermes/runs/<run_id>.json in S3 and tracks which stages are done.
Fetching video metadata
get_video_data fetches metadata and stores it in the manifest:
from typing import Any, Dict
from yt_dlp import YoutubeDL
from agent._core import (
load_manifest,
save_manifest,
sanitize_id,
safe_title,
)
@tool
def get_video_data(video_url: str, run_id: str) -> Dict[str, Any]:
"""Get video metadata and initialise the run manifest."""
run_id = sanitize_id(run_id)
manifest = load_manifest(run_id)
if manifest.get("metadata_done"):
return manifest["metadata"]
ydl_opts = {"quiet": True}
with YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=False)
metadata = {
"title": safe_title(info.get("title") or ""),
"description": info.get("description"),
"duration": info.get("duration"),
"channel": info.get("channel"),
"view_count": info.get("view_count"),
"video_id": info.get("id"),
"video_url": video_url,
}
manifest.update({"metadata_done": True, "metadata": metadata})
save_manifest(run_id, manifest)
return metadata
The agent uses this to decide how “heavy” a workflow is appropriate (e.g., long coding session vs. quick PSA).
Extracting frames and audio
This is where audio and visual context are captured.
import base64
import glob
import io
import os
import tempfile
from typing import Any, Dict
import numpy as np
from moviepy.video.io.VideoFileClip import VideoFileClip
from PIL import Image
from yt_dlp import YoutubeDL
from agent._core import (
BATCH_SIZE,
bucket,
load_manifest,
read_s3,
s3_client,
save_manifest,
summarize_frame_batch,
write_s3,
sanitize_id,
)
def _download_video(video_url: str, temp_dir: str) -> str:
"""Download video to temp_dir and return the resolved local path."""
ydl_opts: Dict[str, Any] = {
"outtmpl": f"{temp_dir}/video.%(ext)s",
"format": "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
"cookiesfrombrowser": ("chrome",),
"extractor_args": {
"youtube": {
"player_client": ["default"],
# Use GitHub-hosted JS player to bypass the bot-detection challenge that blocks yt-dlp.
"ejs_remote_components": ["github"],
}
},
}
with YoutubeDL(ydl_opts) as ydl:
ydl.download([video_url])
downloaded = glob.glob(f"{temp_dir}/video.*")
if not downloaded:
raise FileNotFoundError(f"No video file found in {temp_dir}")
video_path = os.path.realpath(downloaded[0])
# Guard against a malicious or malformed filename resolving outside the temp dir.
if not video_path.startswith(os.path.realpath(temp_dir) + os.sep):
raise ValueError(f"Resolved video path escapes temp dir: {video_path}")
return video_path
def _extract_frames_and_write_audio(video_path: str, audio_path: str) -> list[str]:
"""Extract JPEG frames and write audio track; return list of base64-encoded frames."""
with VideoFileClip(video_path) as clip:
frames: list[str] = []
# Sample one frame every 20 seconds.
for t in np.arange(0, clip.duration, 20.0):
frame = clip.get_frame(t)
img = Image.fromarray(frame.astype(np.uint8))
# 768x768 is the largest size Bedrock's vision model handles efficiently.
img.thumbnail((768, 768))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=75)
frames.append(base64.b64encode(buf.getvalue()).decode("utf-8"))
if clip.audio is None:
raise ValueError("Video has no audio track")
clip.audio.write_audiofile(audio_path, verbose=False, logger=None)
return frames
def _upload_audio(run_id: str, audio_path: str) -> str:
real_audio = os.path.realpath(audio_path)
if not real_audio.endswith(".mp3"):
raise ValueError("Unexpected audio path")
bkt = bucket()
key = f"hermes/audio/{run_id}.mp3"
s3_client().upload_file(real_audio, bkt, key)
return f"s3://{bkt}/{key}"
def _summarize_frames(run_id: str, frames: list[str]) -> str:
summaries = [
summarize_frame_batch(frames[i : i + BATCH_SIZE], i // BATCH_SIZE + 1)
for i in range(0, len(frames), BATCH_SIZE)
]
return write_s3(f"hermes/summaries/{run_id}.txt", "\n\n".join(summaries))
def _process_video(video_url: str, run_id: str) -> Dict[str, Any]:
with tempfile.TemporaryDirectory() as temp_dir:
video_path = _download_video(video_url, temp_dir)
audio_path = f"{temp_dir}/audio.mp3"
frames = _extract_frames_and_write_audio(video_path, audio_path)
audio_s3_uri = _upload_audio(run_id, audio_path)
summary_s3_uri = _summarize_frames(run_id, frames)
return {
"audio_s3_uri": audio_s3_uri,
"visual_summary_s3_uri": summary_s3_uri,
"frame_count": len(frames),
"batch_count": (len(frames) + BATCH_SIZE - 1) // BATCH_SIZE,
}
@tool
def extract_frames_and_audio(video_url: str, run_id: str) -> Dict[str, Any]:
"""Extract frames (summarized via Bedrock in batches) and upload audio to S3."""
run_id = sanitize_id(run_id)
manifest = load_manifest(run_id)
if manifest.get("visual_summary_done") and manifest.get("audio_done"):
return {
"audio_s3_uri": manifest["audio_s3_uri"],
"visual_summary_s3_uri": manifest["visual_summary_s3_uri"],
"frame_count": manifest.get("frame_count", 0),
"batch_count": manifest.get("batch_count", 0),
}
result = _process_video(video_url, run_id)
manifest.update(
{"audio_done": True, "visual_summary_done": True, **result}
)
save_manifest(run_id, manifest)
return result
Instead of returning raw frames to the agent, this tool stores a summarized visual artefact in S3 and returns its URI.
Generating the transcript
generate_transcript now writes the transcript to S3 and returns transcript_s3_uri:
import boto3
import os
import time
from typing import Dict
import requests
from agent._core import (
load_manifest,
save_manifest,
sanitize_id,
write_s3,
)
@tool
def generate_transcript(audio_s3_uri: str, run_id: str) -> Dict[str, str]:
"""Generate transcript from an S3 audio URI using Amazon Transcribe."""
run_id = sanitize_id(run_id)
manifest = load_manifest(run_id)
if manifest.get("transcript_done"):
return {
"transcript_s3_uri": manifest["transcript_s3_uri"],
"status": "completed",
}
transcribe = boto3.client(
"transcribe", region_name=os.environ.get("AWS_REGION", "eu-central-1")
)
job_name = f"hermes-{run_id}"
transcribe.start_transcription_job(
TranscriptionJobName=job_name,
Media={"MediaFileUri": audio_s3_uri},
MediaFormat="mp3",
IdentifyLanguage=True,
)
while True:
status = transcribe.get_transcription_job(TranscriptionJobName=job_name)
state = status["TranscriptionJob"]["TranscriptionJobStatus"]
if state in ("COMPLETED", "FAILED"):
break
time.sleep(5)
if state == "FAILED":
raise RuntimeError("Transcription job failed")
transcript_uri = status["TranscriptionJob"]["Transcript"]["TranscriptFileUri"]
transcript_text = requests.get(transcript_uri, timeout=10).json()["results"][
"transcripts"
][0]["transcript"]
transcript_s3_uri = write_s3(
f"hermes/transcripts/{run_id}.txt", transcript_text
)
manifest.update(
{"transcript_done": True, "transcript_s3_uri": transcript_s3_uri}
)
save_manifest(run_id, manifest)
return {"transcript_s3_uri": transcript_s3_uri, "status": "completed"}
The staged article-generation pipeline
The drafting pipeline lives in agent/_core.py. This is where Hermes.v really changed from the earlier “single prompt” approach.
Key pieces:
-
BATCH_SIZE = 10andCHUNK_CHARS = 12000control batch sizes for frames and transcript chunks. -
summarize_frame_batchuses Bedrock to turn a batch of frames into textual descriptions. -
build_chunk_notesconverts transcript chunks into structured notes. -
build_outlineturns those notes into a sectioned outline. -
build_articlewrites sections one by one, compressing each finished section into a short summary used as context for the next.
Chunk notes and outline
BATCH_SIZE = 10
CHUNK_CHARS = 12000
def summarize_frame_batch(batch: list, batch_num: int) -> str:
content: list = [
{"image": {"format": "jpeg", "source": {"bytes": base64.b64decode(frame)}}}
for frame in batch
]
content.append(
{
"text": (
"These are sequential frames from a technical tutorial video. "
"Describe in detail any source code, CLI commands, architecture diagrams, "
"AWS console UI, configuration files, or other technical content visible. "
"Be precise and include exact code/commands where visible."
)
}
)
return f"[Frames batch {batch_num}]\n" + converse(
[{"role": "user", "content": content}],
label=f"frame-batch-{batch_num}",
)
def build_chunk_notes(run_id: str, transcript: str) -> list[str]:
chunks = [
transcript[i : i + CHUNK_CHARS]
for i in range(0, len(transcript), CHUNK_CHARS)
]
return [
cached_converse(
run_id,
f"chunk-notes-{i + 1}",
[
{
"role": "user",
"content": [
{
"text": (
"Extract structured technical notes from transcript segment "
f"{i+1}/{len(chunks)}. "
"Focus on: concepts explained, commands/code shown, "
f"steps performed, key takeaways.\n\n{chunk}"
)
}
],
}
],
)
for i, chunk in enumerate(chunks)
]
def build_outline(run_id: str, chunk_notes: list[str]) -> str:
text = (
"From these structured notes, identify and list the distinct sections this article "
"should have. For each section, write 2-3 bullet points of key content to include.\n\n"
+ "\n\n---\n\n".join(chunk_notes)
)
candidates = cached_converse(
run_id,
"section-candidates",
[{"role": "user", "content": [{"text": text}]}],
)
return cached_converse(
run_id,
"outline",
[
{
"role": "user",
"content": [
{
"text": (
"Turn these section candidates into a tight, ordered article outline. "
"Each section should have a heading and 3-5 concise bullet points.\n\n"
+ candidates
)
}
],
}
],
)
Section-by-section article drafting
from dataclasses import dataclass
@dataclass
class SectionContext:
run_id: str
title: "str"
outline: str
chunk_notes: list[str]
visual_summary: str
headings: list[str]
previous_summaries: list[str]
def _build_section(ctx: SectionContext, i: int, heading: str) -> str:
n = len(ctx.chunk_notes)
h = len(ctx.headings)
# Distribute chunk notes proportionally across sections so each section gets the notes most relevant to its position in the article.
relevant_notes = (
"\n\n---\n\n".join(ctx.chunk_notes[(i * n) // h : ((i + 1) * n) // h])
# Fall back to the first chunk if the slice is empty (e.g. more sections than chunks).
or ctx.chunk_notes[0]
)
special = ""
if i == 0:
special = (
"\n- Open with a 'Missed the session? Catch up here:' "
"embed block containing the video URL\n- Include prerequisites"
)
if i == h - 1:
special += "\n- End with conclusion and next steps"
prev_context = (
"\n\nPREVIOUS SECTIONS (compressed for context):\n"
+ "\n".join(ctx.previous_summaries)
if ctx.previous_summaries
else ""
)
text = (
f"You are writing a technical tutorial article titled '{ctx.title}'.\n\n"
f"FULL ARTICLE OUTLINE (for global coherence):\n{ctx.outline}"
f"{prev_context}\n\n"
f"RELEVANT CHUNK NOTES FOR THIS SECTION:\n{relevant_notes}\n\n"
f"RELEVANT VISUAL CONTENT (code, CLI, diagrams):\n{ctx.visual_summary}\n\n"
f"Write ONLY the '{heading.lstrip('#').strip()}' section now. "
f"Start with the heading.{special}\n"
"Include all relevant code examples and CLI commands. "
"Format as friendly technical tutorial markdown."
)
return cached_converse(
ctx.run_id,
f"article-section-{i + 1}",
[{"role": "user", "content": [{"text": text}]}],
max_tokens=2048,
)
def build_article(
run_id: str,
chunk_notes: list[str],
outline: str,
visual_summary: str | None,
title: str,
) -> str:
cached = load_draft(run_id, "article")
if cached is not None:
return cached
headings = [
l.strip()
for l in outline.splitlines()
if re.match(r"^#{1,3} ", l.strip())
]
if not headings:
headings = ["## Introduction", "## Main Content", "## Conclusion"]
ctx = SectionContext(
run_id=run_id,
title=title,
outline=outline,
chunk_notes=chunk_notes,
visual_summary=visual_summary or "",
headings=headings,
previous_summaries=[],
)
sections: list[str] = []
for i, heading in enumerate(headings):
section_text = _build_section(ctx, i, heading)
sections.append(section_text)
# Compress each completed section to 1-2 sentences before passing it as
# context to the next section. Keeps the prompt small while preserving
# enough continuity for the model to avoid repetition and maintain flow.
text = (
"Summarise this article section in 1-2 sentences for use as context:\n\n"
)
summary = converse(
[
{
"role": "user",
"content": [{"text": text + section_text}],
}
],
max_tokens=128,
label=f"summary-article-section-{i + 1}",
)
ctx.previous_summaries.append(
f"- {heading.lstrip('#').strip()}: {summary}"
)
article = "\n\n".join(sections)
save_draft(run_id, "article", article)
return article
This is the heart of the long‑video fix, the model never has to see the entire transcript and visual summary in one call. Instead, it works on one chunk or section at a time with cached intermediates.
Gluing it together
The tool that calls this pipeline is generate_article_draft in tools.py:
@tool
def generate_article_draft(
run_id: str,
title: str,
transcript_s3_uri: str,
tags: list,
visual_summary_s3_uri: str | None = None,
) -> Dict[str, Any]:
"""Generate article draft via chunked notes > section candidates > outline > article.
Each stage is persisted and skipped on retry."""
run_id = sanitize_id(run_id)
manifest = load_manifest(run_id)
if manifest.get("article_done"):
return {"article_s3_uri": manifest["article_s3_uri"], "status": "exists"}
try:
chunk_notes = build_chunk_notes(run_id, read_s3(transcript_s3_uri))
outline = build_outline(run_id, chunk_notes)
article = build_article(
run_id,
chunk_notes,
outline,
read_s3(visual_summary_s3_uri) if visual_summary_s3_uri else None,
safe_title(title),
)
except (ClientError, RuntimeError) as exc:
logging.getLogger(__name__).error(
"Article generation failed | run_id=%s error=%s", run_id, exc
)
return {
"status": "generation_failed",
"error": type(exc).__name__,
"article_s3_uri": manifest.get("article_s3_uri"),
"retry_from": "generate_article_draft",
}
article_s3_uri = write_s3(f"hermes/articles/{run_id}.md", article)
manifest.update(
{
"article_done": True,
"article_s3_uri": article_s3_uri,
"tags": safe_tags(tags),
}
)
save_manifest(run_id, manifest)
return {"article_s3_uri": article_s3_uri, "status": "generated"}
Publishing to Dev.to
publish_devto_article reads the generated markdown from S3 and posts it to Dev.to as a draft:
@tool
def publish_devto_article(
run_id: str, title: str, article_s3_uri: str, tags: list
) -> Dict[str, Any]:
"""Publish a generated article from S3 to Dev.to as a draft."""
run_id = sanitize_id(run_id)
manifest = load_manifest(run_id)
if manifest.get("published_done"):
return {
"status": "already_published",
"article_url": manifest.get("article_url"),
}
content = read_s3(article_s3_uri)
api_key = os.environ.get("DEVTO_API_KEY")
if not api_key:
return {
"status": "skipped",
"message": "DEVTO_API_KEY not set.",
"article_s3_uri": article_s3_uri,
}
org_id = os.environ.get("DEVTO_ORG_ID")
main_image = os.environ.get("DEVTO_MAIN_IMAGE")
headers = {
"accept": "application/vnd.forem.api-v1+json",
"Content-Type": "application/json",
"api-key": api_key,
}
article: Dict[str, Any] = {
"title": safe_title(title),
"body_markdown": content,
"published": False,
"tags": safe_tags(tags),
}
if org_id:
article["organization_id"] = int(org_id)
if main_image:
article["main_image"] = main_image
response = requests.post(
"https://dev.to/api/articles",
headers=headers,
json={"article": article},
timeout=30,
)
if response.status_code == 201:
article_url = response.json()["url"]
manifest.update({"published_done": True, "article_url": article_url})
save_manifest(run_id, manifest)
return {"status": "success", "article_url": article_url}
return {
"status": "error",
"message": f"HTTP {response.status_code}",
"article_s3_uri": article_s3_uri,
}
If you don’t set DEVTO_API_KEY, the tool simply returns the markdown and marks publishing as skipped, so you can paste the article wherever you want.
Example: catching up a backlog
One of the first real tests for Hermes.v was my AWS Cloud Path series. For example, the article “AWS Cloud Path Week 10: Important Things to Know About Networking in AWS” came from a live session in that playlist. Before Hermes, each follow‑up article meant rewatching the stream and reconstructing everything I had explained on the fly.
Once I had Hermes.v stable, I used it for Weeks 11–18 as well. The workflow was:
- run Hermes.v on the YouTube URL,
- let it produce a Dev.to draft,
- then spend my time only on editing and polishing.
That shift meant I could clear a large publishing backlog quickly, editing and publishing multiple articles in two days instead of delaying them for weeks.
How you can try it
This project is published on my github page here. The quickest way to try Hermes.v is:
- Deploy the CDK stack (as shown earlier).
- Configure
.envwith at leastAWS_REGION,HERMES_S3_BUCKET, andBEDROCK_MODEL_ID(or rely on the default Sonnet 4.6). - Optionally add Dev.to credentials.
- Run.
make run
Hermes will ask you for a YouTube URL, optional title, and tags, then orchestrate the full pipeline to produce a draft.
Thanks for reading ;D
Hermes.v started as a way to reduce the friction between the content I create on YouTube and the written content I want to share with the community. By combining AWS Bedrock, Amazon Transcribe, and a simple Dev.to integration, I’m able to turn a single URL of my own video content into a solid first draft that I can refine instead of writing from scratch. This has been especially useful because all of my streams were raw unfiltered content without a script or content prep; I’d otherwise need to rewatch every video to even remember what I touched on or talked about.
If you build something similar or fork Hermes.v to add new features like multi‑language support, experimenting with other models, better tag generation, or richer image handling, I’d love to hear about it. Feel free to share your experiments or questions in the comments, or reach out if you want to chat about this project.


Top comments (0)