An AI video editor you talk to. Say "remove the silences" or "caption every clip", and an agent makes the edit on your real timeline: it reads the project, decides which cuts to make, and applies them. Anything destructive stops and asks you first.
The tedious parts of editing are the ones a machine should do. Cutting dead air out of a twenty-minute take is thirty minutes of scrubbing; here it is one sentence and one approval click. Captioning every clip means transcribing each one by hand; here a sub-agent handles each clip in parallel and the captions land on their own track, timed. The agent does the mechanical work, and you keep the decisions: it proposes, you approve, and every edit is undoable.
| Ask for this | What the agent does |
|---|---|
| "Remove the silences" | Finds every silent range on the voice track and ripple-deletes it across all tracks, closing the gaps. Verified: 24s → 21.1s, exactly the 2.9s of silence. |
| "Caption every video clip" | Fans out one sub-agent per clip to transcribe in parallel, merges the results, and lays timed captions on a new track. |
| "Cut the intro to 3 seconds" | Trims the clip, keeping the media in sync by moving its source offset. |
| "Duck the music under the voiceover" | Sets clip volume. |
| "Split this at 15 seconds" | Cuts a clip in two, both halves still frame-accurate. |
| "Export it at 1080p" | Renders, after you approve. |
The timeline is exposed to the agent as 16 MCP tools, not as a prompt describing a timeline. The agent calls real functions against real state:
- Read:
get_project,transcribe_clip,find_silences,detect_beats,list_changes - Write:
split_clip,trim_clip,move_clip,set_volume,add_text,add_captions,undo - Destructive:
delete_clip,ripple_delete,remove_silences - Gated:
export_project
Every tool validates its input and returns errors to the model as data, so a stale clip id becomes
a correction the agent recovers from rather than a failed turn. The destructive four are published
with MCP's destructiveHint annotation, which is what makes the harness stop and ask you before
they run.
Because it is MCP, the same agent can reach anything else that speaks MCP: web search, your issue tracker, an internal API you wrap yourself. Connectors attach by name and authorize in chat.
EditAI is built on TrueForge, an open-source agent harness. The harness runs the agent loop; EditAI supplies the domain.
browser harness domain
┌──────────────┐ HTTP ┌──────────────┐ MCP ┌──────────────────┐
│ editor UI │◄─────────►│ TrueForge │◄────────►│ @editai/agent │
│ (apps/web) │ + SSE │ agent loop │ │ 16 timeline │
│ │ │ approvals │ │ tools │
│ timeline ◄──┼───────────┼──────────────┼── SSE ───┤ project store │
└──────────────┘ └──────────────┘ └──────────────────┘
The editor never calls a model. It creates a session, streams turn events, renders tool calls and sub-agent threads, and answers the harness when it pauses. The timeline redraws from the agent server's event stream, so an edit the agent makes shows up in the UI as it happens.
| Capability | Where it lives |
|---|---|
| MCP tools, your own and the catalog's | apps/agent/src/tools.ts; setup.ts also attaches catalog servers, including OAuth ones (verified against Linear) and keyless web search |
| Human approvals before destructive edits | MCP destructiveHint annotations → require_approval_for_tools → the approval card in apps/web/src/components/editor/assistant-panel.tsx |
| Sub-agents | Enabled on the agent; per-clip fan-out for captioning, rendered as threads in the UI |
| Sessions that survive a reload | apps/web/src/components/editor/use-assistant.ts replays turns and re-attaches to a running one |
| Live web research | bright-data connector: search_engine and scrape_as_markdown, attached deferred so it costs no context until a task needs it (see docs/brightdata.md) |
| Any model provider | apps/agent/scripts/setup.ts registers whichever API keys are present, including any OpenAI-compatible endpoint |
| Sandboxed execution | Two layers: the harness sandbox (Daytona, configured automatically when DAYTONA_API_KEY is set) for general code, and packages/ffmpeg-sandbox for media work |
You need Bun and Node 22.14+ (for the harness).
bun install
# 1. the harness
npx @truefoundry/trueforge@latest # http://localhost:8790
# 2. the timeline tools
cd apps/agent && bun run start # http://localhost:8941
# 3. wire them together (any one key is enough)
ANTHROPIC_API_KEY=sk-... bun run setup
# 4. the editor
cd ../.. && bun run dev:web # http://localhost:5173Then ask for an edit: "Remove the silences", "Caption every video clip".
Without the harness running, the editor still loads with a sample timeline; the assistant panel says it is offline.
- TanStack Start + React 19, Vite, Tailwind CSS v4, shadcn/ui
- TrueForge agent harness, MCP tools
- Cloudflare Workers (via Wrangler), Bun + Turborepo monorepo
apps/
web/ the editor: timeline, preview, assistant panel
agent/ MCP server exposing the timeline, plus the agent definition
See apps/agent/README.md for the tool reference and timeline semantics.
| Command | What it does |
|---|---|
bun run dev |
Run every app in dev mode |
bun run dev:web |
Run only the web app |
bun run build |
Build every app |
bun run deploy |
Build and deploy to Cloudflare |
Generated code never runs on the host. Two layers cover the two kinds of work.
Harness sandbox (Daytona). setup.ts registers the provider when
DAYTONA_API_KEY is present, and the agent's exec tool then runs in a remote
sandbox. Verified end to end against the running harness:
sandbox.created sandbox_id: v1:daytona:default.e17058ee-...
exec python3 -c "print(sum(int(x)**2 for x in range(1,101)))"
tool.response {"success":true,"response":{"exitCode":0,"result":"338350\n"}}
Media sandbox (packages/ffmpeg-sandbox). ffmpeg and ffprobe are not safe to
point at agent-supplied arguments on the host, so every invocation runs in a
throwaway container: --network none, capped memory and CPU, --pids-limit 256,
a non-root user, a single mounted workspace, and a hard timeout. run_python
there is annotated destructiveHint: true, so the harness shows the script and
waits for approval before it runs, because a script with the workspace mounted
read-write can delete the source media and the container is not a defence
against that.
The split is deliberate: the harness sandbox is for computation the agent should not estimate, and the media sandbox is for work that must reach the media files.
Qodo posts its verdict as an issue comment. It publishes no check run, no commit
status and no approving review, so GitHub's own auto-merge has nothing to gate
on. .github/workflows/qodo-automerge.yml is that missing gate.
It listens for edited comments as well as created ones, because Qodo posts a
placeholder and then edits the verdict into that same comment: a gate watching
only for new comments never sees a verdict at all. On an edited event
comment.user is still the bot even when a person did the editing, so the
sender is checked too.
It fails closed in every direction, because the first draft did not and Qodo
said so. It reads only the structured counter chips, never the prose: Qodo
quotes findings and diff hunks verbatim, so the words "no issues found" appear
inside reviews that are not clean, and any substring test on the comment body
is forgeable by the pull request's own content. It binds the verdict to the
commit Qodo footers in the comment and refuses to merge when that is no longer
the head, since a review applies to one revision and issue_comment runs give a
job no link to the pull request head. It treats a failure to read check state as
an error rather than as an absence of failures. And it merges with
--match-head-commit, so a push racing the merge is rejected by GitHub instead
of slipping in.
PR #3 was reviewed with Qodo Merge, which raised three issues. All three were real and all three are fixed in the PR:
- Duplicate clip ids after repeated ripple deletes (
apps/agent/src/project.ts). The right-hand half of a split clip took the id${c.id}r, so a clip cut more than once produced the same id twice. Reproduced on the sample project:removeSilencesleft three clips sharingc6rand three sharingc7r, which breaks clip lookup, deletion and React keys. Ids are now allocated from the set of ids in use. Covered by three regression tests. - Export announced before the file existed.
exportProjectcommitted the record, which notifies SSE subscribers synchronously, and only then wrote the file. The write now happens first. - The session-restore effect leaked its stream on unmount, calling
setStateon a gone component and leaving the connection open. Its cleanup now aborts the controller.
The first finding is the one that mattered: the test suite had missed it, because the existing
ripple-delete test asserted the buggy c1r id as if it were correct.
A second review of the updated PR raised three more. Two were real and fixed; one was checked and rejected:
- Real: a header-auth connector whose env var had no
: valueregistered an empty header and failed silently. It now reports the expected format instead. - Real:
setup.tsPOSTs API keys to the harness, so it now refuses to do that over plaintext HTTP to anything but localhost. - False positive: the reviewer called the source-media bound in
trimClip(end - (c.start - c.sourceOffset)) wrong and predicted a clip could be extended to 28s instead of 18s. The expression expands toc.sourceOffset + (end - c.start), which is the correct source time, and the code accepts exactly up to the limit and rejects one frame past it. Two tests now pin that boundary so the correct form is not "fixed" into a broken one later.
PR #2 raised two, both real and both fixed:
run_pythoncould delete the source media. The tool passed agent-supplied code straight topython3 -c, and the argument guard could not catch it:import shutil; shutil.rmtree('/work')contains no..and no leading/, so it passed. The container is no defence either, since the workspace is exactly what it is meant to reach. The tool is now registered withdestructiveHint: true, so the harness stops and shows the script for approval first, the same gate the timeline'sdelete_clipandripple_deleteuse.- The path guard rejected valid code. Scanning a Python source string for
..or a URL rejected correct programs without adding a boundary. Path validation now applies only to path-like arguments; for code payloads the container is the boundary.
Issues and pull requests are welcome. See CONTRIBUTING.md for setup and guidelines, and open an issue first for anything larger than a bug fix.