seshat
seshat records screen output/regions and narrates completed takes using caller-supplied prose and ingested event timelines.
Inspect recorder state, active take, stream/recording directories, capture outputs, and narration dependencies.
Start MP4/WebM/GIF recordings of an output or region, silent by default, with optional monitor/mic audio, timeline-source filtering, and max duration.
Stop recordings and poll lifecycle status through recording, processing, narrating, completed, or failed phases.
Retrieve the take's filtered monotonic event timeline for use as narration anchors.
Attach caller-written narration, synthesize speech, align segments by event_id or at_ms, mix/duck existing audio, and burn captions; README notes SRT/WebVTT sidecars are also written.
Generate approximate scene-cut anchors, optionally with OCR, when no timeline stream exists.
Per README, also manage durable takes (list/export/discard) and post-produce renders with trim, idle, zoom, markers, profiles, poster, teaser, chapters, stitching, and narrated cuts.
It does not control the desktop or generate narration prose; it records, times, synthesizes, captions, and muxes what the caller and other tool servers provide.
Integrates with the Sway window manager by using swaymsg/SWAYSOCK to resolve output geometry for screen recordings and ingesting timeline event streams published by Sway-driving tool servers such as computer-use-sway, enabling event-aligned narration over captured video.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@seshat record my screen, then narrate it with captions"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
seshat
An MCP server that records a screen and narrates the take.
seshat captures an output (or a region of one) into a silent artifact, ingests the timeline streams that other tool servers publish while the take runs, and then muxes a synthesized narration track — aligned to those real events, with styled captions burned in — over the recorded video. The prose is the caller's; the timing, the speech, the captions and the container are seshat's.
It is named after the Egyptian goddess of writing, measurement and record-keeping.
Sesh is the mascot: an ivory scribe-keeper crowned with Seshat's star and horns, the recording lens set in her chest, the goddess's notched measuring rod — the take timeline — in her hand, and the events other servers publish trailing from her headcloth. She keeps the book; she never invents a word of it.
What it is, and what it is not
It is a recorder and a narrator. It owns capture, the take timeline, speech synthesis, caption layout and muxing.
It is not a desktop controller. It never clicks, types, or focuses anything. The actions a take documents are performed by other tool servers; they reach seshat as published event streams. That split is deliberate: a recorder that also drives the desktop cannot record a desktop driven by anything else.
It embeds no model. Narration prose is written by the calling agent and passed in. seshat owns timestamping, scheduling, synthesis and multiplexing: the same inputs, timeline and synthesized clips always produce the same schedule, and narration always rebuilds its deliverable from the take's finalized master, never from a previous narration's output.
Related MCP server: casefile-mcp
Requirements
Python 3.10+ (standard library only —
dependencies = []is a hard contract).A live Sway or Hyprland session (Omarchy is Hyprland). The compositor is detected from the session environment and can be forced with
SESHAT_COMPOSITOR=sway|hyprland; a missing IPC variable (SWAYSOCK,HYPRLAND_INSTANCE_SIGNATURE) is recovered from the runtime directory, which is how a harness-launched server still finds the desktop.wf-recorderfor capture,ffmpeg+ffprobefor finalization, muxing and probing;hyprctlorswaymsgto resolve output geometry. Caption burn-in additionally needs an ffmpeg built with libass — thesubtitlesfilter — which minimal builds ship without. A missing binary is reported as a clear tool error, never an import failure.Optional:
piper(offline — theengine="auto"default needs a voice model) oredge-tts(network — runs only whenengine="edge"is passed explicitly, because it sends the narration prose to Microsoft);tesseractfor OCR of scene keys.
On Arch or Omarchy:
sudo pacman -S --needed wf-recorder ffmpeg tesseract # hyprctl ships with Hyprland
uv tool install edge-tts # or piper + a voice modelmake check runs the unit tests, bytecode compilation, and the file-length check.
make help lists every target; make ci-local runs the whole gate plus a
distribution build, and make runtime shows the streams, recordings and active
take when something looks wrong.
Install
uv venv
uv pip install -e .
seshat --doctor # what this host can record and narrate with
seshat --self-test # non-mutating checksRegister with an MCP harness:
hermes mcp add seshat --command /path/to/venv/bin/seshat
codex mcp add seshat -- /path/to/venv/bin/seshatAgent skill
skill/seshat/SKILL.md is the operating procedure for an agent driving this
server: the take lifecycle, the anchor rules, and the failure modes that produce
silent or unanchored takes. It ships in the source distribution, and the copy in
this repository is the source of truth — installed copies are copies.
# opencode
mkdir -p ~/.config/opencode/skill
cp -r skill/seshat ~/.config/opencode/skill/
# Hermes
mkdir -p ~/.hermes/skills/media
cp -r skill/seshat ~/.hermes/skills/media/Re-run the copy after changing the skill; a stale installed copy is how an agent ends up calling tools this server no longer has.
Recording a take
recording_start(format="mp4", max_duration_seconds=120)
... the demonstration happens ...
recording_stop()
recording_status() # poll until phase == completed | failed
recording_timeline() # the ingested events, with recording-relative t_msOne take may be active at a time. Artifacts are written to
$XDG_RUNTIME_DIR/seshat/recordings (0700, files 0600); the live capture
intermediate lives there because tmpfs is fastest. A completed take is
adopted into the durable store $XDG_STATE_HOME/seshat/takes/<id>/
(default ~/.local/state/seshat/takes/<id>/, 0700/0600) — master, timeline
sidecar, manifest and every narration request — and is rehydrated by the next
server session: takes survive restarts, and only recording_discard deletes
them. recording_export is the only way take files leave the store:
recording_list() # every take this process knows
recording_export(destination="/path") # copy master, deliverable, sidecars, manifest
recording_discard(id="...") # delete one stored take (irreversible)Takes are recorded silent by default. To capture desktop audio into the artifact, ask for it at start:
recording_start(format="mp4", audio="monitor") # the desktop's own sound
recording_start(format="mp4", audio="mic") # the host's default microphoneaudio="monitor"records the default output sink's monitor — application and system sound, never the microphone — resolved frompactl infoand passed towf-recorder -a <sink>.monitor. A host without a resolvable default sink fails with a clear error;seshat_info.audioreports what would be captured.audio="mic"records PulseAudio's default source (the default input device).audio="auto"ismonitorwhere a default sink exists,micotherwise.audio="off"(default, or null) records no audio.Audio is for
mp4/webm;gifcannot carry audio and refuses the argument.Narration added later blends the synthesized voice with the captured sound in the artifact's single audio stream — see Narrating a take.
A completed take separates three lifecycle facts: capture_elapsed_seconds runs
from recorder launch to the stop request, shutdown_latency_seconds measures how
long the recorder then took to exit, and media_duration_seconds is the playable
picture. termination_stage and recorder_returncode expose how the process
ended. Capture is damage-independent, so static screen intervals still advance
the video; latest_event_ms and events_beyond_media identify any remaining
timeline/media mismatch rather than hiding it.
make check # unit tests, compilation, file-length ceiling
SESHAT_INTEGRATION=1 make integration # records the live screen; opt in explicitlyThe timeline stream contract
seshat cannot timestamp actions it does not perform. Any tool server that wants
its actions to be narratable publishes them — one JSON object per line — to
$XDG_RUNTIME_DIR/seshat/streams/<source>.jsonl:
{"at_monotonic": 12345.678, "tool": "click", "ok": true,
"payload": {"x": 640, "y": 360}, "source": "computer-use-sway"}at_monotonicis required: CLOCK_MONOTONIC seconds, the value oftime.monotonic()in the emitting process. That clock is host-wide, which is what makes an unrelated process's timestamps directly comparable with the recording epoch — no handshake, no session id.toolis required.ok(default true),payload(default{}) andsource(default: the file stem) are optional.Each emitter owns its file and should truncate it when a new session starts.
Events are filtered to the take's own capture window. Anything published before
recording_startor after capture stopped describes something the video does not contain, and is not an anchor.Malformed, oversized or unreadable lines are counted in the timeline's
sourcesreport and skipped. A broken stream can never destroy a take.
The contract is driver-agnostic. On Sway,
computer-use-sway plays
this emitter role; on Hyprland or Omarchy, whatever drives the desktop can
publish the same lines from the process that performs the actions. A driver
that publishes nothing still records fine, but the take has no event anchors —
narrate with at_ms or fall back to recording_scenes.
Pass timeline_sources to recording_start to ingest specific files instead of
everything in the streams directory; explicit paths are validated when the take
starts, not when it is finalized.
Narrating a take
Read the timeline first, then write prose against the event ids that are really
there (recording_status(wait_seconds=45) below blocks until terminal instead
of forcing a poll per turn):
recording_timeline()
recording_voiceover(segments=[
{"anchor": {"event_id": 1}, "text": "..."},
{"anchor": {"at_ms": 8200}, "text": "..."}
])
recording_status() # poll until phase == completed | failedrecording_voiceover synthesizes each segment, builds one audio track placed at
the resolved anchors (with offset_ms, optional tempo compression via
fit="compress", and lead-silence trimming), mixes it with whatever audio the
take already carries — over a take recorded with audio=monitor|mic, the
captured desktop sound is ducked under the voice by a sidechain compressor and
the whole mix is normalized to −16 LUFS; over a silent take, that stream is the
voice alone — and muxes it over the video. The voice is tunable: rate,
volume and pitch are passed to edge-tts, length_scale to piper, and the
result reports what was used (character).
Captions are split into sentence-shaped phrases, timed by the engine's own cues
where they match the phrase and proportionally by characters otherwise, burned
unless subtitles=false, and written as SRT and WebVTT sidecars beside every
deliverable either way — so players, YouTube and translators get the timings
even when this ffmpeg build lacks libass. position="auto" lifts a caption to
the top when the events inside its window acted in the bottom third of the
picture (the events are read for placement, never for prose).
Plan before you record. dry_run=true answers synchronously with the
schedule — every segment's synthesized duration and any overruns past the
master's extent — without muxing or publishing, so a demonstration can be paced
to the script before it is recorded.
The deliverable is written to <id>.narrated.<fmt> (or <id>.<variant>.<fmt>
with variant="it", for narration in another language from the same take and
master) and published only after validation; the finalized master at the take's
master_path is never modified, so re-rendering always starts from the clean
capture. The video stream is re-encoded only where work is done on it — caption
burn-in, or extending the picture with cloned frames when the narration runs
past the recorded extent (reported as narration.extended_ms) — and
stream-copied otherwise. The result is re-validated: exactly one video stream
plus exactly one audio stream. A narration failure never destroys the take: the
master stays in place and the reason lands in result.narration.error — for
example, an ffmpeg build without the subtitles filter cannot burn captions
(the sidecars are still written). Retry that take with subtitles=false. To
burn captions, start the server with a libass-enabled ffmpeg on PATH before
recording; restarting the server to change PATH forgets its in-memory
state.
Anchors are checked against the master's picture. event_id, at_ms and
match ({"tool", "payload", "nth"} — exactly one real event, so a script
survives a re-record) are validated against the playable video extent — the
video stream's own duration, falling back to the container's — so speech cannot
be anchored
after the last frame. A segment that would run past that frame is not dropped:
the picture is extended with cloned frames to cover it. An event id that exists
but resolves past the end of the master's video is still refused, with both
numbers in the message.
If recording_timeline reports no events, the demonstration was driven by a tool
server that does not publish a stream. Anchor segments with at_ms, or use
recording_scenes for approximate cuts. Scene cuts are secondary evidence;
they are never the sync source.
Post-production
recording_render post-produces a completed take over its own ingested
timeline — nothing in the edit looks at the picture, so every cut traces back
to an event:
trimkeeps first-event to last-event plus a pad (default 300 ms).idlespeeds up every event-free gap pastmin_gap_ms(the picture and the captured audio re-time together).zoomholds a hard-cut zoom window centered where the events acted, forhold_ms, then releases;markerspulses a box at the same points.profilerescales to1080p/720p, or cropsnativetovertical(9:16) around the median pointer position.poster,teaserandchaptersderive a poster PNG, a GIF teaser, and chapters from the events whose payloads carry achapterlabel — muxed into the render and written as a YouTube-style list beside it.stitchconcatenates whole finalized masters of the same shape in order, at the container level; a shape or codec mismatch is a refusal, not a half-re-encoded catalog.narrate(with the narration arguments:engine,voice,fit,position,subtitlesand the voice character) speaks the cut itself: every segment's anchor resolves on the render's own event map —at_msagainst the render's extent, anevent_idas a position in the map, a contentmatchagainst the remapped rows — and the track is scheduled against the render and muxed to<id>.render.narrated.<fmt>, with the cut's own audio as the ducked bed. The cut is published before its narration runs, so a narration failure leaves a complete picture with the reason in the render'snarration.error.
The master is never modified; a render is a new <id>.render[.<profile>].<fmt>
file in the take's store directory, validated before it is published. The
result reports every event's remapped t_ms, so a script narrates a render
with at_ms anchors against the render's own timeline — or passes the prose
straight to the render in narrate and takes the narrated cut from one call.
The render answers synchronously.
What the events do and do not prove
An event records that a tool call was dispatched, not that it visibly worked.
The driving server is the only party that knows whether its action had the
intended effect; a driver that returns sent: true without verifying the result
will publish an event for an action that did nothing. Two open issues in the
sibling project are exactly this shape: a modified key that arrived as an
unmodified one, and a keystroke delivered to a window that had stolen focus.
So narrate what the take shows. Use event ids to place a line in time, and ground its content in the video, in the driver's own verified payload, or in something you observed yourself — never in the mere existence of an event.
Security notes
Audio capture is opt-in and source-resolved. seshat records sound only when
recording_startsays so, and records the desktop output's monitor — never the microphone — unless you explicitly ask formic. A take never silently starts listening to anything.edge-ttsruns only when asked for.engine="auto"resolves to the offlinepiperengine (install one and a voice model, or providevoice);engine="edge"is an explicit opt-in to send the narration prose to Microsoft. The narration result recordsengine,voiceandnetwork.Narration text is the caller's; seshat never reads it from the screen, so nothing visible on screen becomes narration unless the agent says so.
Recordings live under
$XDG_RUNTIME_DIR/seshatwhile captured and move to$XDG_STATE_HOME/seshat/takes/<id>/when finalized — 0700 / 0600 everywhere. Files leave the store only throughrecording_exportand are deleted only throughrecording_discard.
FAQ
Why doesn't my recording have any sound?
Because you didn't ask for it. Takes are silent by default — recording_start
captures only a picture unless you pass audio="monitor" (desktop sound),
audio="mic" (default microphone) or audio="auto". This is deliberate: a
recorder that silently starts listening to a microphone is a security problem,
not a convenience. seshat_info.audio shows what a take would capture.
I passed audio="mic" and got my microphone, but I wanted the desktop's
sound. Which is which?
monitor = what your speakers play (application and system audio, not the
microphone). mic = the host's default input device, whatever that is. On a
host with several microphones, the default one wins; pick input routing in your
audio mixer (pavucontrol / wpctl) if you need a different one. There is one
exception to know about: if no default output sink exists (headless session,
no audio hardware), auto falls back to mic.
Where is my microphone in the recording?
Nowhere unless you asked: a take that captured the microphone was started with
audio="mic" or audio="auto" on a mic-only host. Desktop audio comes from the
sink's monitor and never carries the microphone, and vice versa — one -a
source per take.
GIF refused my recording with "gif cannot carry audio" — why?
GIF containers cannot carry any audio stream, so there is no such thing as a
sound-encoded GIF. Record format="mp4" or "webm" for takes that need audio;
keep GIF for silent, looping previews.
My narration is too loud / too quiet against the desktop audio. Can I mix the
levels?
The mix is automatic: the captured desktop sound is ducked while the voice
speaks and the whole deliverable is normalized to −16 LUFS integrated, so takes
are level-consistent across a channel. The voice itself is tunable with rate,
volume and pitch (edge) or length_scale (piper). If it is still hard to
mix against unusually loud desktop sound, narrate a silent take (default
audio="off") when the voice is the point.
Can I get narration and desktop audio in the same file?
Yes — that is exactly what an audio-captured take narrated later produces.
Desktop audio is recorded into the master by recording_start(audio=...), and
recording_voiceover mixes the synthesized voice with it, ducking the desktop
sound while the voice speaks; both are audible in the deliverable's single audio
stream. The captured track and the voice are not separate selectable tracks in
the file — that one stream is their mix — and the master keeps its own track
untouched for a later re-render.
Why does seshat_info.audio show null for my sink?
pactl info answered without a default sink: no audio server is running, or the
session's user owns none. Recording silently still works. Ask for audio="mic"
only if default_source resolves, and otherwise record silent.
The recording fails with "could not resolve a default audio sink" when I asked
for audio="monitor".
That host has no default output sink to monitor. Record with audio="mic", or
without audio at all. Setting an output sink (wpctl) does not change seshat's
mind mid-take: it resolves the sink at the moment the take starts; a take that
already started keeps the source it resolved.
My take says audio_included: true but I hear nothing when I play it.
Two normal causes: the desktop was quiet when the take ran (the desktop's sound
is recorded only if it played during the capture window — seshat does not create
sound), or the player you tried doesn't handle the container's audio track (MP4
= AAC, WebM = Opus; both are common, try VLC or mpv).
Related
computer-use-sway— drives a Sway session and publishes the timeline stream that this server ingests. Recording and narration were extracted from that project so that both could stand on their own.omarchy-computer-use— gives an agent its own nested Hyprland session on Omarchy. It does not publish a timeline stream, so takes it drives narrate viaat_msorrecording_scenesunless something else publishes for them.
Available Tools
7 toolsrecording_scenesA
Optional, approximate fallback anchors for a completed take: ffmpeg scene-cut timestamps, with optional keyframe OCR via tesseract. Use when no timeline stream was published for the take; scene cuts are secondary evidence, never the sync source.
Inputs: id, ocr, threshold, max_scenes.
recording_startA
Start recording an active output (or a region within it) into a silent artifact: H.264 MP4 by default, or AV1 WebM, or a constrained GIF fallback. Returns immediately; call recording_stop to finish, then poll recording_status until the phase is completed or failed. The pointer cursor is always included. Narration anchors come from the timeline streams ingested for this take, so the tool server that drives the demonstration has to publish one.
Inputs: audio, format, output, region, +2 more.
recording_statusB
Report the take lifecycle phase (idle, recording, stopping, processing, narrating, completed, failed) plus live progress or final artifact metadata.
Inputs: id.
recording_stopA
Gracefully stop the active recording and begin finalizing the requested artifact. Returns the stopping/processing state; poll recording_status for the final artifact path and metadata.
Inputs: no named parameters.
recording_timelineA
Return the take's monotonic event timeline: every event ingested from the take's timeline streams, filtered to the capture window, with a recording-relative t_ms, the emitting source, and a compact payload. Use event ids as narration anchors. A sidecar JSON copy is written next to the artifact on completion.
Inputs: id.
recording_voiceoverA
Attach a scripted narration track to a completed take: the caller supplies the prose, the server synthesizes speech, aligns segments to timeline anchors (or to an explicit at_ms), and muxes the audio over the existing video stream, optionally burning styled captions. Starts an async 'narrating' phase; poll recording_status. GIF cannot carry audio.
Inputs: segments (required), id, fit, voice, +4 more.
seshat_infoB
Inspect the recorder: active take, stream and recording directories, the streams that will be ingested, the capture outputs, and the binaries narration depends on.
Inputs: no named parameters.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 7 tools
Tools cover distinct lifecycle stages and resources: start/stop/status are separate actions, timeline vs scenes are distinguished by primary vs fallback evidence, and voiceover is a distinct post-processing step. Minor potential overlap between seshat_info and recording_status over active-take state, but descriptions clarify their different scopes.
Six core tools consistently use recording_<noun> (start, stop, status, timeline, scenes, voiceover), which is predictable. The lone seshat_info breaks the prefix/pattern slightly, but the set remains readable.
Seven tools are well-scoped for a recording-and-narration server, covering lifecycle, inspection, timeline, fallback scenes, and voiceover without bloat. Each tool has a clear role.
The surface covers the core record→stop→status→narrate lifecycle plus timeline/scene anchors and environment inspection. Minor gaps such as no explicit cancel/pause or take-listing/deletion exist, but agents can work around them.
Maintenance
Related MCP Connectors
The AI video studio: records your web app, writes and voices the script, renders a tutorial MP4.
The AI video studio: records your web app, writes and voices the script, renders a tutorial MP4.
Turn screen recordings into step-by-step guides, SOPs, tutorials and user docs with screenshots.
Turn URLs, recordings, docs, assets, or ideas into launch films, demo recordings, and decks.
Related MCP Servers
- AlicenseNot gradedqualityBmaintenanceEnables an AI agent to operate a web app, record a deterministic session, and compile an editable project into a polished MP4 with zooms, cuts, captions, callouts, and reproducible renders.MIT
- AlicenseBqualityCmaintenanceEnables AI assistants to convert screen recordings of test sessions into narrated, captioned, highlighted walkthrough videos and subtitle files, using offline text extraction and local rendering.9MIT
- AlicenseBqualityFmaintenanceEnables an agent to drive and record macOS apps with real clicks, typing and taps, then analyze the footage and edit it live in the screen recorder's editor with cuts, speed-ups, zooms, narration, captions and music, and export sized variants. Supports seven editing recipes, brand kits, privacy blurring, undo-safe checkpoints, and use from Claude Code or Codex.4632 npmMIT
- AlicenseNot gradedqualityCmaintenanceEnables AI agents to film tabs, apps, or simulators while driving them, then render narrated demo videos with zoom effects, cursor and click visuals, captions, backgrounds, and share pages.1MIT