| name | teemule |
| description | Drive interactive terminal sessions for AI agents. Start a virtual terminal, send commands, read streamed output, and detect when a program is ready for input. Use when an agent needs to run an interactive CLI, REPL, TUI, or shell (e.g. open a terminal to drive another tool like Codex) and respond to prompts in a loop. Backed by a PTY with event-sourced, hash-chained output. |
teemule — interactive terminals for agents
teemule lets you spawn a process inside a pseudo-terminal and interact with it
the way a human would: type commands, read what it prints, and react when it is
waiting for input. Every byte the program emits (and every byte you send) is
recorded as an immutable, tick-indexed, hash-chained frame, so you can poll for
changes, replay history, and never miss output.
Two ways to use it
-
MCP tools (preferred for agents) — run the MCP server and call its tools.
See teemule mcp --transport stdio (for Claude Code / CLI agents) or
--transport http (for remote agents).
-
CLI (quick, one-shot) — teemule exec and teemule run.
Windows + AV: required one-time setup
On Windows, reputation-based antivirus (Norton SONAR, Avast, Windows
Defender SmartScreen) gates CreateProcess on freshly-built unsigned Go
binaries — teemule's spawn calls hang indefinitely while the scanner
inspects the child. Add AV exclusions for the repo's bin/ and tmp/
directories (and your $TEMP if go build writes there). Without
exclusions, start_session will appear to hang at spawn time; with them,
the agent path drives real TUIs (claude, vim, fzf) end-to-end.
CLI recipes
teemule profiles --profiles ./profiles
teemule exec bash --profiles ./profiles --input "echo hello"
teemule exec bash --input "ls -la" --out session.raw --text-out session.txt
teemule run bash --profiles ./profiles
exec sends --input as keystrokes (Enter is appended) and streams the
program's output to stdout until the session ends. Use --input-file for
multi-line or binary input. --timeout caps how long to wait. --out,
--text-out, and --jsonl-out tee the session to files (raw / ANSI-stripped /
one-JSON-frame-per-line); they apply to exec and run.
MCP tools
Start the server: teemule mcp --transport stdio. Then call:
| tool | input | returns |
|---|
start_session | {profile} or {command,args,env,dir,rows,cols} | {id,pid,profile,...} |
send_input | {id,data,enter} | {ok} |
send_key | {id,key} | {ok} |
type_text | {id,text,pace_ms?} | {ok} |
read_output | {id,since,limit} | {frames:[{tick,...}]} |
read_screen | {id} | {rows,cols,cursor,grid,text} |
await_ready | {id,timeout_ms} | {ready,observations?} |
list_sessions | {} | [{id,...}] |
get_session | {id} | {id,...} |
resize_session | {id,rows,cols} | {ok} |
close_session | {id} | {ok} |
Driving a TUI (claude-code / vim / fzf / menus)
For full-screen TUIs, three new surfaces matter more than the byte stream:
send_key — press a named key. Enter, Tab, Esc, Backspace,
arrows (Up/Down/Left/Right), Home/End, PageUp/PageDown,
Insert/Delete, F1-F12, and chords C-<x> (Ctrl), M-<x>/A-<x>
(Alt/Meta), S-<arrows> (Shift). teemule emits the canonical xterm
bytes; callers never hand-build \x1b[A.
type_text — type a string in small paced chunks (default 8 runes,
~12ms apart). The pacing fixes burst-write races where a TUI's input
parser strands the final byte (the observed "Enter didn't submit"
failure with Claude Code). Pass pace_ms:0 for known-calm programs.
read_screen — see the FINAL rendered cell grid (cursor motion
applied, overwrites win, scrolls applied), not a concatenation of every
repaint. The response includes rows, cols, cursor ({row,col}
0-indexed), grid ([[{rune,bold,underline,...}]]), and a pre-rendered
text field for callers that just want to grep.
Wired via the profile's screen interpreter, configured like any other:
interpreters:
- kind: screen
params: {rows: 40, cols: 120}
- kind: stable
params: {after: 1500ms}
The interaction loop (TUI)
1. start_session {profile:"claude-code"} → remember `id`
2. await_ready {id, timeout_ms:30000} → wait for first paint
3. type_text {id, text:"<the question>"} → paced typing
4. send_key {id, key:"Enter"} → submit
5. await_ready {id, timeout_ms:60000} → wait for screen to settle
6. read_screen {id} → read the rendered answer
7. goto 3 for follow-ups; close_session when done
The interaction loop (how to drive a program)
start_session {profile:"bash"} → remember id.
await_ready {id, timeout_ms:2000} → wait until the program shows a prompt.
send_input {id, data:"ls -la", enter:true}.
read_output {id, since: <last tick>} → read new frames; advance your cursor
to the highest tick you've seen.
await_ready again, then repeat 3–5 for each command.
close_session {id} when done.
since is a monotonic per-session tick; passing the last tick you read returns
only new frames, so you can poll efficiently. Each frame carries a hash and
prev_hash forming a chain, so output is tamper-evident and changes are
pinpointable.
Profiles
A profile declares a kind of terminal: how to launch it, how to detect "ready",
and which interpreters format the stream. They live as YAML in a profiles
directory (default ./profiles, override with --profiles or
TEEMULE_PROFILES). Create new ones with teemule init.
name: bash
command: bash
args: ["--norc", "-i"]
readiness_mode: any
interpreters:
- kind: prompt
params: {pattern: '\$ $'}
- kind: idle
params: {after: 500ms}
- kind: ansi
- kind: lines
timeouts: {start: 5s, idle: 500ms, command: 30s}
sinks:
- {path: /tmp/bash.raw, format: raw}
- {path: /tmp/bash.txt, format: text}
Built-in interpreter kinds: prompt, idle, ansi, lines.
Plugins (custom interpreters)
Interpreters are the extension point. There are two ways to add one:
-
Built-in kinds (ansi, lines, idle, prompt, nx) are compiled into
teemule and available everywhere. nx (profile/nx.go + profiles/nx.yaml)
is the in-process reference: it parses an Nx task run — the project:target
pipeline (so you can confirm frontend:build ran before backend:serve),
the served URL, and failures — and provides readiness once the URL appears.
test/nx-workspace + integration/nx_test.go drive a real nx serve backend
end to end through a PTY.
-
Out-of-process plugins — any executable that speaks JSON-RPC 2.0 over
stdin/stdout, discovered from disk. teemule spawns one per session, feeds it
the output stream, and reads back readiness + observations. Write them in Go,
TypeScript, Python, bash — anything. No recompile, no Go toolchain needed to
add one. They live under .teemule/plugins/ (project) or ~/.teemule/plugins/
(global, overridable via TEEMULE_HOME); project plugins override global ones
on a kind clash. Reference plugins for gofastr / Express / FastAPI / a
generic bash dev-server live in examples/plugins/, and the full protocol is
in docs/plugins.md. A minimal no-SDK Go reference is
plugin/testdata/echoplugin/main.go.
Either way, a profile references it as kind: <name> and gets it wired up with
no core changes.
Disk sinks (raw / transformed)
Any interaction can be teed to one or more files as it streams. Three formats:
raw — the program's exact output bytes (ANSI/control sequences preserved),
so cat file reproduces the terminal.
text — ANSI-stripped, human-readable.
jsonl — one JSON record per frame (tick, kind, text, hash, …),
structured and replayable.
From the CLI: --out, --text-out, --jsonl-out on exec/run. From a
profile: the sinks: block (applies to every session of that profile, across
CLI/HTTP/MCP). From HTTP/MCP start_session: a sinks field. Files are
flushed and closed when the session closes.